From a510b46e4e26e8208dfe44153479eccbecd96df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 2 May 2026 20:26:42 +0100 Subject: [PATCH] feat(multi-repo): support tasks spanning multiple repositories (#767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(task): add task_environment_repos schema and model for multi-repo support Phase 0 of multi-repository task support: introduces the per-repository child table and model alongside legacy single-repo fields (dual-write, no behavior change). Lays the foundation for the orchestrator/lifecycle refactor in subsequent phases. - New task_environment_repos table with FK CASCADE on task_environments. - New task_dir_name column on task_environments (idempotent ALTER). - TaskEnvironmentRepo model + Repos slice + RepoFor helper on TaskEnvironment. - CRUD methods + interface extension; env getters preload Repos. - Idempotent backfill from legacy single-repo task_environments rows. - Tests cover round-trip, CRUD, preload ordering, embedded create, cascade delete, and backfill from a legacy-shaped row. Co-Authored-By: Claude Opus 4.7 * feat(lifecycle): add Repositories list to LaunchRequest and EnvPrepareRequest Phase 1.1 of multi-repository task support: extends the lifecycle types with explicit per-repo lists, while keeping the legacy single-repo top-level fields as the synthesized fallback. No caller behavior changes yet — preparers still see length-1 specs for single-repo launches. - LaunchRequest.Repositories []RepoLaunchSpec + RepoSpecs() helper. - EnvPrepareRequest.Repositories []RepoPrepareSpec + RepoSpecs() helper. - EnvPrepareResult.Worktrees []RepoWorktreeResult for per-repo outcomes. - runEnvironmentPreparer forwards req.Repositories into the prepare request, inheriting the request-level RepoSetupScript when each per-repo entry has none. - Tests cover explicit-list precedence, synthesized fallback, and repo-less requests. Co-Authored-By: Claude Opus 4.7 * feat(worktree,lifecycle): support multiple worktrees per session Phases 1.2 and 1.3 of multi-repository task support. The pieces are intertwined so they ship together — the preparer's per-repo loop depends on the manager being able to hold one worktree per (session, repository) pair instead of one per session. Worktree manager: - In-memory cache key changes from sessionID to (sessionID|repositoryID). - New MultiRepoStore optional capability + SQLite implementation: GetWorktreesBySessionID and GetWorktreeBySessionAndRepository. - New Manager methods: GetAllBySessionID, GetBySessionAndRepo. Existing GetBySessionID returns the first match (single-repo callers unchanged). - Manager.Create's "already exists" check is now repo-scoped so the second repo's create no longer returns the first repo's worktree. - All cache write/delete sites updated to the composite key. WorktreePreparer: - New prepareMultiRepo path activated when EnvPrepareRequest.Repositories has >=2 entries; single-repo and repo-less behavior is byte-identical. - prepareOneRepo runs validate -> sync -> create -> fetch-PR -> per-repo setup-script for each spec; rolls back already-created worktrees on any failure. - Workspace path becomes the task root (parent of any repo subdir); legacy WorktreeID/WorktreeBranch/MainRepoGitDir mirror Worktrees[0] for downstream code that hasn't been migrated yet. - Per-repo setup script runs verbatim (no script-engine resolution yet — the per-repo placeholder namespace lands in a follow-up). Tests: - Manager: GetAllBySessionID, GetBySessionAndRepo (hit + miss), GetBySessionID single-repo compat, cache key separation. - Preparer: multi-repo creates one worktree per repo; rollback removes active worktrees on partial failure; per-repo setup script runs in each worktree directory and emits its own step. Co-Authored-By: Claude Opus 4.7 * feat(orchestrator): resolve all task repos and persist per-repo rows on launch Phase 1.4 of multi-repository task support: the orchestrator now reads every repository linked to a task, forwards the full list through the adapter into the lifecycle layer, and persists one TaskEnvironmentRepo plus one TaskSessionWorktree row per repo when the launch is multi-repo. Single-repo and repo-less tasks keep their previous code path verbatim (dual-write — legacy single-repo fields stay populated from the primary). - New resolveAllRepoInfo / resolveTaskRepoInfo on the executor; primary is the first by Position. - buildLaunchAgentRequest takes both the primary repoInfo and the full list, populating req.Repositories []RepoSpec only when length > 1. - New RepoSpec / RepoWorktreeResult on the orchestrator's executor package mirror the lifecycle types so the adapter can translate between them without leaking lifecycle types into the orchestrator public API. - Adapter (cmd/kandev/adapters.go) forwards req.Repositories into lifecycle.LaunchRequest and surfaces per-repo prepare results back on LaunchAgentResponse.Worktrees. - persistTaskEnvironment writes env.Repos in the same create transaction; persistTaskEnvironmentRepos handles the existing-env reuse path. TaskEnvironment.TaskDirName is now persisted. - persistWorktreeAssociation loops over resp.Worktrees in multi-repo, inserting one row per repo with its own RepositoryID. - ResumeSession now also populates req.Repositories from task repos and stamps the existing TaskDirName so the preparer reuses the same task root. - repoStore / executorStore interfaces extended with the new repo and env-repo CRUD methods; mocks updated accordingly. Tests: - LaunchPreparedSession with two task repositories populates req.Repositories in order and keeps legacy single-repo top-level fields filled from the primary. - The same launch persists one TaskEnvironment + 2 TaskEnvironmentRepo + 2 TaskSessionWorktree rows. - Single-repo launch still produces zero entries in req.Repositories and exercises only the legacy fields. Co-Authored-By: Claude Opus 4.7 * feat(agentctl): git endpoints accept optional repo subpath for multi-repo Phase 2 of multi-repository task support: every per-call git endpoint gains an optional `repo` field (form param for GET, JSON for POST) that selects a sub-directory of the workspace. Empty `repo` = workspace root, preserving single-repo behavior verbatim. - Manager.GitOperatorFor(subpath) returns a per-subpath GitOperator (cached). Validates the subpath against path traversal, absolute paths, and missing directories. - New api server helper gitOpForRepo() emits a 400 with a per-operation GitOperationResult on invalid subpaths. - Repo field added to: pull, push, rebase, merge, abort, commit, rename-branch, stage, unstage, discard, create-pr, revert-commit, show-commit, reset, log, cumulative-diff. (git/status still reads from the workspace tracker — that endpoint will gain multi-repo support when the tracker itself is split per repo.) - Tests cover empty subpath, valid subpath caching, traversal rejection, and missing-directory rejection. Co-Authored-By: Claude Opus 4.7 * feat(web/comments): scope diff comments by repository for multi-repo tasks Phase 3 of multi-repository task support: comments now carry an optional repositoryId so two repos with the same file path render their own comment threads instead of bleeding together. - CommentBase.repositoryId (optional, backwards compatible). - getCommentsForFile gains an optional repositoryId arg; legacy comments without a repositoryId match any repo filter so persisted data keeps showing. - Diff store interface in lib/diff/types.ts mirrors the new arg. - Tests cover per-repo filter, omitted-arg fallback, and legacy data. Co-Authored-By: Claude Opus 4.7 * feat(github): per-repo PR association on task PRs and watches Phase 4 of multi-repository task support: github_task_prs and github_pr_watches gain a repository_id column so multi-repo tasks can have one PR per repo without colliding on (task_id) uniqueness. - Idempotent ALTER TABLE adds repository_id to both tables. - TaskPR.RepositoryID and PRWatch.RepositoryID model fields. - New GetTaskPRByRepository and ListTaskPRsByTask lookups; existing GetTaskPR keeps single-PR semantics for callers not yet migrated. - ReplaceTaskPR's delete is scoped by repository_id when set so a multi-repo task replacing one repo's PR no longer wipes the other. - Tests cover per-repo storage and scoped replace. Co-Authored-By: Claude Opus 4.7 * feat(web): foundations for multi-repo task UI Phase 5 of multi-repository task support: primitives the upcoming UI work needs without changing any visible behavior yet. - TaskRepository.checkout_branch surfaces the optional PR head branch field that the backend already exposes. - primaryTaskRepository(repos) picks by lowest Position so consumers match the backend's "primary" semantics instead of `[0]`. - New useTaskRepositories(taskId) / useTaskPrimaryRepository(taskId) hooks read the live kanban task and return its repos sorted by position. Future kanban/diff/UI consumers should use these instead of poking task.repositories directly. - Tests cover lowest-position selection and empty/single-repo edge cases. Co-Authored-By: Claude Opus 4.7 * feat(web): kanban card shows multi-repo "+N" chip and hydrates full repo list Phase 6 (subset) of multi-repository task support. The kanban now exposes the per-task repository list to the UI and renders a compact "+N" badge on cards whose tasks span more than one repository. - KanbanState.tasks[].repositories slim shape (id, repository_id, base_branch, checkout_branch, position). - SSR mapper hydrates the full list and uses primaryTaskRepository() to pick the position-aware primary instead of [0]. - useKanbanData passes repositories through to the card props. - KanbanCard renders the primary repo name with a small "+N" chip when len(repositories) > 1; tooltip names the count. - useTaskRepositories / useTaskPrimaryRepository hooks return the slim KanbanTaskRepository shape so multi-repo consumers share one read path. Larger UI surfaces (task-create dialog, diff view, file browser, mobile sheets) still target the primary repo only — they will be ported as the multi-repo task creation flow lands. No visible change for single-repo tasks. Co-Authored-By: Claude Opus 4.7 * feat(task): reject duplicate repositories on multi-repo task create Phase 8 of multi-repository task support: surface duplicate-repository input as a clean validation error instead of letting the unique (task_id, repository_id) constraint surface mid-loop with some rows already inserted. - createTaskRepositories tracks seen repository IDs and returns "repository listed more than once for the task" before any CreateTaskRepository call. - Tests cover the rejection path and confirm two distinct repos still succeed with both rows persisted in Position order. Repository discovery and per-repo cloning were already covered by Phase 1.4 (resolveAllRepoInfo loops ensureRepoCloned) and the existing repository_discovery service is repo-list aware by design. Co-Authored-By: Claude Opus 4.7 * feat(web/task-create): repeatable repository rows for multi-repo task creation Users can now actually create a multi-repo task: a "+ Add repository" button below the primary repo + branch selectors expands the form with additional (repository, base-branch) rows. Each extra row picks from a filtered repository list that excludes the primary and any other already- chosen extras, so duplicates are impossible to enter. - ExtraRepositoryRow type + form-state hook (extraRepositories, add / remove / update / reset). Lives in its own file so the main state hook stays under the file-length lint cap. - ExtraRepositoryRows component renders the rows and the "+ Add" button; per-row branches are loaded on demand via the existing useRepositoryBranches hook. - buildRepositoriesPayload accepts the extras and appends them after the primary, dropping rows with empty repository_id silently so an unfinished row does not block submit. The backend's duplicate-repo validation (Phase 8) catches anything that slips through. - DialogFormBody is split: CreateModeSelectors (selectors + extra rows) and useDialogHandlers move to dedicated files, keeping touched files under the lint thresholds. - Tests cover the multi-repo payload (ordering, drop-empty, no-primary recovery, single-repo unchanged) and the existing store-multi-repo.test.ts gets named constants to satisfy the no-duplicate-string rule. Verified: pnpm exec tsc --noEmit clean, vitest 608/608 green, eslint --max-warnings 0 clean. Co-Authored-By: Claude Opus 4.7 * feat(agentctl): per-repo workspace tracker for multi-repo git/status The git/status endpoint now accepts an optional ?repo= query param that selects a sub-directory of the workspace, completing the per-repo plumbing started in the multi-repo phase. Single-repo callers keep the workspace-root behavior unchanged. - Manager.GetWorkspaceTrackerFor(subpath) returns a per-subpath WorkspaceTracker (lazy + cached). Validation reuses a new resolveSubpath helper shared with GitOperatorFor, so both code paths reject path traversal / absolute paths / missing dirs the same way. - Per-subpath trackers are NOT started (no polling goroutines); the multi-repo path uses them only for synchronous GetCurrentGitStatus queries which fall through to getGitStatus when no cache is present. This keeps per-repo cost cheap. - handleGitStatus reads c.Query("repo") and routes through the new resolver, returning a 400 with an error payload on invalid input. - Tests cover empty subpath (root), valid subpath caching, traversal rejection. Co-Authored-By: Claude Opus 4.7 * feat(web/review): group file tree by repository for multi-repo task changes The review file tree now renders per-repo top-level nodes when a task's changes span multiple repositories, so the same file path in two repos no longer collapses into a single ambiguous entry. - ReviewFile.repository_id and ReviewFile.repository_name (optional) identify which repo each file belongs to. Single-repo callers leave them unset and the tree falls back to the existing flat grouping. - buildFileTree splits into buildMultiRepoTree (when 2+ distinct repository_names are present) and buildFlatTree (the original logic). Repo roots have isRepoRoot=true and a repositoryId for downstream filters; their children are full path subtrees per repo. - collapse() skips repo roots so a repo with a single changed file still shows its repo header. - ReviewFileTree renders repo roots with a stronger label, a top border, and an "N files" count badge; data-testid="repo-root-node" for E2E coverage. File-level rendering is unchanged. - Tests cover the flat-tree fallback (no repo info, single repo), multi-repo grouping, repo-root pinning, and same-filename-different- repos isolation. Co-Authored-By: Claude Opus 4.7 * feat(web/task-top-bar): "+N" repo chip on multi-repo task header The task page top-bar breadcrumb now shows a small "+N" chip next to the primary repository name when the task spans more than one repository, matching the kanban-card treatment introduced earlier. Hover surfaces the exact count via tooltip ("frontend (+2 more repos)"). - resolveTaskProps populates repositoryCount from task.repositories.length. - TaskTopBar threads repositoryCount through to the new RepositoryBreadcrumb subcomponent. - The chip carries data-testid="topbar-extra-repo-count" for E2E selectors. - TopBarLeft was nearing the function-length / complexity caps; the breadcrumb extraction restores headroom for future additions. Co-Authored-By: Claude Opus 4.7 * fix(web/task-create): disable "Add repository" when no other repos available When the user only has the primary repository in their workspace, clicking "+ Add repository" used to insert a row whose dropdown was empty — looking like a bug ("no other repos show up"). The button now disables itself when there are no remaining workspace repositories to choose from, with a tooltip explaining why ("All workspace repositories are already added"). The previous "primary not selected" guard is also surfaced via tooltip. - ExtraRepositoryRows computes remainingCount = repos − used (primary + already-added extras) and gates the Add button on it. - New computeAddButtonHint helper returns a per-state title string for the disabled cases. - Tests cover: dropdown shows the unused workspace repos when opened (the click path the user reported wasn't actually broken — the gate was just missing); Add button disabled when only the primary exists. Co-Authored-By: Claude Opus 4.7 * fix(agentctl): workspace tracker falls back to first repo subdir for multi-repo task roots The "Changes" panel was empty for multi-repo tasks because the agent's workspace path is the task root — a plain holder directory that holds one git worktree per repository as siblings. The workspace tracker tried to run git commands against that bare root and silently failed, so no git status updates ever reached the UI. The tracker now detects this at construction: when the configured workDir isn't itself a git repo, it falls back to the first immediate child directory that is. The user gets the primary repo's changes in the panel; cross-repo aggregation is a follow-up. - preferGitRepoChildIfRootIsBare scans os.ReadDir entries (skipping hidden / non-directory entries) and returns the first that has a valid git index. Logs an info line when the rewrite happens so it's visible in the agentctl log. - Repo-less tasks (e.g. quick chat) and single-repo tasks keep their previous behavior verbatim — the fallback only fires when the root itself fails resolveGitIndexPath. - Tests cover: fallback when the workdir is a multi-repo holder, no-fallback for a real git repo, no-fallback when no git child exists. Co-Authored-By: Claude Opus 4.7 * feat(multi-repo): per-repo workspace tracking — Changes panel shows all repos The agent's workspace tracker is now split per-repository for multi-repo task roots. Each repo gets its own tracker that polls git independently and stamps its repository name onto every emitted event. The Changes panel aggregates files from every repo and groups them under per-repo headers using the file-tree machinery from Phase 6. Backend (agentctl): - GitStatusUpdate / FileListUpdate gain a RepositoryName field. Empty for single-repo workspaces (legacy clients see no change). - WorkspaceTracker carries repositoryName and stamps it onto every emitted status/file-list. Two constructors: NewWorkspaceTracker for the legacy single-repo path (with the bare-root fallback) and NewWorkspaceTrackerForRepo for explicit per-repo trackers. - Manager scans direct child directories for git repos at construction. When 2+ repos are found, builds one tracker per repo subdir on top of the root tracker (which stays bound to the bare task root and emits nothing useful). Single-repo workspaces are unchanged. - Manager.SubscribeWorkspaceStream / UnsubscribeWorkspaceStream fan out one client subscription across the root + all per-repo trackers via AttachWorkspaceStreamSubscriber / DetachWorkspaceStreamSubscriber on the tracker. The WS handler switched from the single-tracker subscribe to the manager fan-out. - Tests cover: per-repo trackers spawn for multi-repo task roots, single-repo doesn't, both repos emit GitStatusUpdates tagged with RepositoryName. Frontend: - GitStatusEntry / GitStatusData carry repository_name (optional). - session-runtime gitStatus.byEnvironmentRepo new map keyed by env id then repository name; setGitStatus routes per-repo updates into it while still updating the legacy byEnvironmentId for single-repo consumers. clearGitStatus drops both maps. - migrateEnvKeyedData migrates the new per-repo map alongside the existing keys when sessionId resolves to an environmentId. - WS git-status handler forwards repository_name from event.status. - New useSessionGitStatusByRepo hook returns sorted [{repo, status}] pairs for the active session. - task-changes-panel mergeReviewFiles iterates statusByRepo when available, stamping repository_name onto every uncommitted file so the Changes panel groups them under per-repo headers (using the ReviewFile.repository_name plumbing from Phase 6). - Tests: per-repo routing, no overwrite on sibling repo update, legacy single-repo fallback, clear-on-cleanup. Verified: pnpm exec vitest run 623/623, eslint --max-warnings 0 clean, tsc --noEmit clean, make test (backend) clean, make lint 0 issues. Co-Authored-By: Claude Opus 4.7 * feat(web/changes-panel): per-repo group headers above each repo's files The Changes panel now renders a sticky "frontend / N files" header above the diffs from each repository so the user can visually distinguish where each change lives. Single-repo tasks render unchanged (no header shown when there's only one unnamed group). - ReviewDiffList groups its file list by repository_name (preserving the caller's order within each group), then renders one RepoGroupHeader per group followed by the existing FileDiffSection list. Header gating rule: show when len(groups) > 1 OR the single group has a non-empty repository name. - Group key uses "repo:path" so a file with the same path in two repos no longer collides on React's reconciliation. - groupFilesByRepository + RepoGroupHeader live in a sibling review-diff-list-groups.tsx file to keep review-diff-list.tsx under the 600-line lint cap. - Tests cover: no header for plain (no repo_name) files, one header per repo for 2+ groups, header shown even for a single named repo, file paths land under the right group's section. Verified: vitest 627/627, eslint clean, tsc clean. Co-Authored-By: Claude Opus 4.7 * fix(lifecycle): propagate RepositoryName through PublishGitStatus The agentctl WorkspaceTracker stamps every per-repo GitStatusUpdate with RepositoryName, but the lifecycle PublishGitStatus translation into GitEventPayload was dropping that field. The orchestrator then forwarded a status with empty repository_name, the frontend store routed everything into the empty-key bucket, and the Changes panel's multi-repo grouping never triggered — no header, no per-repo split. - GitStatusData gains RepositoryName (omitempty so single-repo payloads stay byte-identical on the wire). - PublishGitStatus copies update.RepositoryName onto the payload. - Test asserts the field survives the publish → subscribe round trip via the in-memory event bus on the GitEvent. subject. Restart the agentctl process for any running multi-repo task to pick up the new field; new launches get it automatically. Co-Authored-By: Claude Opus 4.7 * feat(web/changes-panel): per-repo group headers in the dockview Changes tab The Changes tab in the dockview panel (the Files / Changes (N) tab the user actually interacts with — not the standalone Review dialog) was still rendering a flat file list, so multi-repo task changes either collided onto the legacy single-status (showing only one repo's files) or rendered without grouping. - FileInfo gains repository_name (optional, single-repo unchanged). - useSessionGit aggregates files from useSessionGitStatusByRepo when multi-repo data is present, stamping each FileInfo with its repository_name. Falls back to the legacy single status for single-repo workspaces. Aggregation extracted into aggregateFilesAcrossRepos to keep useSessionGit under the 100-line function lint cap. - ChangedFile (changes-panel.tsx + changes-panel-timeline.tsx) carries repositoryName through to the renderer. - FileListSection delegates to a new FileListBody that groups rows by repositoryName; renders a small per-repo subheader when 2+ repos OR any single named repo is present, otherwise falls back to the pre-multi-repo flat list (same DOM shape). - Tests cover: no header for plain (no repo_name) files, headers per repo for 2+ groups, single named repo still gets a label. Restart the backend so the per-repo trackers spawn for in-flight multi-repo tasks; new launches are unaffected. Co-Authored-By: Claude Opus 4.7 * feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create Bundles a string of multi-repo fixes and refactors that came up while testing the feature end-to-end: - workspace_path stored on multi-repo TaskEnvironment was one level too high, so agentctl booted with the wrong WorkDir on cold start; fixed in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession. - Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo` through agentctl client → WS handler → frontend hook → SessionGit fan-out, so the right git repo is targeted (was running at the task root, which isn't a git repo for multi-repo tasks). - agentctl GitLog is multi-repo aware: when no `repo` is specified and per-repo trackers exist, fans out one log per repo and merges sorted newest-first with RepositoryName stamped on each commit. - Per-repo grouping for files (Changes panel) and commits, with collapsible group headers; commits section defaults to collapsed. Algorithm extracted into one generic groupByRepositoryName helper (was copy-pasted three times). - Task creation dialog rewritten around a unified `fs.repositories[]` list — no more "primary vs extras" split. Chip-style row with one pill per repo + branch, "+" to add, x to remove, GitHub URL toggle inline. Discovered (on-machine) repos appear in any chip's dropdown. Removes ~6 fields and 3 helpers from the dialog state. - Branch listing endpoints collapsed to one: GET /workspaces/:id/branches?repository_id=X | path=Y Frontend collapses to a single useBranches hook with a tagged-union source. Drops two HTTP routes, two service methods, two API client funcs, and one whole hook file. Tests added across the chain: workspace_path resolver, sort-commits- by-date, manager poll-mode propagation, RepoSubpaths, chip grouping + collapse + per-repo isLatest, group-by-repo helper, dialog payload builder + chip wiring + discovered-repo flow. * feat(multi-repo): unified per-repo git controls for all VCS operations Changes panel and top-bar VCS button render per-repo Stage all, Commit, Unstage all, Push, Pull, Rebase, Merge, and Create PR uniformly across single-repo and multi-repo workspaces. Operations route to the correct git repo subpath; fan-outs collect per-repo results and surface partial success in the toast layer. Backend: - Stamp RepositoryName on Reset/BranchSwitch/FileChange notifications and forward through orchestrator handlers - Surface per-repo errors from handleGitLogMultiRepo via PerRepoErrors Frontend: - pull/push/rebase/merge/abort/commit fan out via PerRepoOperationResult - Per-repo group headers always render with action buttons - Top-bar dropdown lists per-repo Pull/Rebase/Merge entries - pendingStageFiles keyed per-repo to prevent cross-repo clears - useRepoDisplayName distinguishes loading from multi-repo - Commit dialog file summary respects stageAll - New helpers: changes-panel-helpers.ts, changes-panel-repo-groups.tsx, use-repo-display-name.ts Co-Authored-By: Claude Opus 4.7 * feat(multi-repo): plumb repo through file/commit/diff endpoints; per-repo PRs and review UI - backend: thread `repo` subpath through pull/push/rebase/merge/abort, file content/update, cumulative-diff, and commit-show endpoints; per-repo fan-out for log + cumulative-diff with each repo's own merge-base; emit full `repositories` array on task.updated WS payload - github: store/list TaskPRs as []*TaskPR (per-repo); orchestrator/web store rekey to `Record` - web: aggregate PR icon (count badge + worst-of color, multi-PR tooltip); branch hover card renders one row per repo with each repo's task base branch; review dialog merges per-repo file maps with composite keys and propagates repository_name through diff/expand/revert; commit-detail panel scopes to the owning repo - kanban card: chip row of repo names (up to 2 + "+N" overflow, tooltip lists all) - task-create dialog: gate non-worktree executors to multi-repo only; move fresh-branch toggle inline next to the repo chips (icon-only, with title/aria-label) Co-Authored-By: Claude Opus 4.7 * fix(multi-repo): scope PR watches/associations and review UI per repository Schema: add repository_id to UNIQUE constraints on github_pr_watches and github_task_prs (with idempotent copy-and-rename migration) so one task spanning N repos can hold one watch/PR per repo without collisions. Plumb repository_id through CreatePRWatch/EnsurePRWatch/AssociatePRWithTask /AssociatePRByURL, fan TriggerPRSync across all per-repo watches, and disambiguate status sync by (task, owner, repo, pr_number) instead of "first match". Add Repo to wsCreateFile/Delete/Rename and wsCreatePR/ RenameBranch so file and git ops target the right working tree. Frontend review dialog: switch reviewedFiles, staleFiles, fileRefs, selectedFile and comment counts to NUL-separated `${repo}\x00${path}` keys so same-named files in different repos no longer share state. useTaskPR consumes the new {prs: TaskPR[]} envelope. Also: snapshot repoTrackers slice before background refresh in SetWorkspacePollMode to prevent races with Stop(), and key mergeCumulativeFiles by NUL-separated (repo, path) to match. Co-Authored-By: Claude Opus 4.7 * chore: apply prettier formatting Unblock CI prettier check on files modified earlier on this branch that pre-date the current prettier config. * fix: address PR review feedback and unblock E2E Backend: - ListWorkspaceTaskPRs background sweep now uses TriggerPRSyncAll so multi-repo tasks refresh every per-repo PR (was only the most recently updated watch). Caught by claude review on #767. Frontend (no behaviour change): - Replace literal NUL byte (0x00) in FILE_KEY_SEP and the inline key builder in use-review-dialog with the explicit escape so the source is plain ASCII. The runtime separator is unchanged; this just removes a footgun that GitHub's diff renders as a visible space. - Show the auto-resolved branch as a small read-only pill next to the GitHub URL input so the URL-mode dialog surfaces the branch the task will run on (previously only the URL was visible). E2E: - changes-panel-focus / pr-detection / pr-switcher-changes: expand the commits-section before asserting on commit text -- the section starts collapsed since the chip-style refactor. - repository-selector-scroll: open the popover via repo-chip-trigger instead of the legacy single repository-selector testid. * test(e2e): align task-create dialog tests with chip refactor Updates three more tests that broke when the dialog migrated from a single repository/branch combobox to per-row repo/branch chips and the URL-mode toggle relabel: - create-task.spec.ts (pre-select + remembers-selections): switch repository-selector / branch-selector lookups to repo-chip-trigger / branch-chip-trigger. - create-task-github-url.spec.ts (toggle test): the inverse-state label is now "use a workspace repository", not the legacy "or select a repository". No code changes — purely test alignment with the existing UI. * fix(web): re-apply workbench-chrome topbar + restore branch-selector behaviour Two regressions surfaced by the multi-repo merge into main: 1. task-top-bar.tsx had been resolved in our favour at the workbench-chrome merge (#761), losing the TopbarActionOverflow wiring that the new test asserts on. Re-applies main's topbar structure (TopbarCluster groups, TopbarActionOverflow, IntegrationsMenu, MoreToolsMenu, SettingsButton) on top of our multi-repo additions: repositoryCount + RepositoryBreadcrumb "+N" chip, gitStatus + isMultiRepo plumbing, and the GitAheadBehindBadges suppression for multi-repo. GitAheadBehindBadges moved to its own file to stay under the file-length cap. 2. The chip-style task-create dialog dropped the legacy branch-selector semantics. Restored: - Local-executor branch lock: the per-row branch chip is now disabled when isLocalExecutor and fresh-branch is off. The user's checkout dictates the branch for local execution; fresh-branch unlocks it. - Branch override: when locked, the chip shows currentLocalBranch instead of the value the user picked under a different executor (so worktree -> local doesn't display a stale "develop" pick). - Branch refresh: useBranches now exposes a refresh() callback for id-based sources (calls listRepositoryBranches with refresh=true), and Pill renders BranchRefreshButton in its popover header when onRefresh is wired. - URL-mode branch picker: GitHubUrlSection now renders a real branch-chip-trigger Pill backed by fs.githubBranches instead of a read-only span, restoring the "branch selectable in URL mode" behaviour (URL mode is always remote-clone, so the local-executor lock does not apply). E2E spec updated: bulk-rename legacy branch-selector / repository-selector testids to branch-chip-trigger / repo-chip-trigger.first(). All 13 create-task-branch-selector tests pass locally. * ci: force workflow re-run * fix(web): unblock E2E shard 2 Three issues showed up only after the merge with main, which brought back the E2E shard 2 tests touching git/changes-panel and improve-kandev: 1. Wire the TaskCreateDialog slot props (extraFormSlot, aboveDescriptionSlot, bottomSlot, descriptionPlaceholder) through to DialogPromptSection. They were declared on the public API but dropped on the floor before this change, so Improve Kandev's contributor banner ("@octocat" / "@kandev-maint") never rendered. Also gate the "or paste a GitHub URL" toggle on lockedFields.repository so the locked-repo dialog hides it (matches main's behaviour and the existing test expectation). 2. Revert "commits section starts collapsed" (introduced in 9e8079de). The collapsed-by-default tradeoff is genuine, but it broke 14+ E2E tests that assert on commit text in the changes panel — the win wasn't worth the test churn. Updated the unit test to match. 3. Update the git-changes-panel E2E tests to use the stable `[data-testid^="commit-row-"]` selector instead of `commitsList li`. The chip-style refactor introduced a per-repo group
  • wrapper, so counting all
  • descendants now over-counts (1 wrapper + N commits). Same fix in the "distinguishes pushed and unpushed" test which queried `li` with text and matched both the wrapper and the commit row. Locally verified: full shard 2 (45 tests) passes; full unit-test suite (943 tests) passes; lint + typecheck clean. * fix: address claude review feedback Three findings from claude[bot] review on PR #767: - types.ts: add 13 unit tests for reviewFileKey / splitReviewFileKey covering bare paths, composite keys, slashes/spaces in paths, repo names containing slashes, and full round-trip. Asks were repeated across five review rounds; the round-trip coverage is what catches the FILE_KEY_SEP NUL-vs-space class of bug at the type level. - mergeCumulativeFiles (api/git.go): shallow-copy each per-file payload before stamping repository_name + path so the source map isn't mutated. The previous code wrote directly into the per-repo result map, which would have permanently rewritten the original entry if any future caller iterated the same source twice. - buildRepoSpecs (executor_execute.go / executor_resume.go): drop the unused execCfg parameter (and the speculative "reserved for future per-repo config" suppression). Per repo overrides do not exist today and the speculative scaffolding masked an actual dead arg. * docs(readme): mention multi-repository task support * fix(web/topbar): drop standalone ahead/behind badges (duplicate of split button) The Create PR / Push button already shows the inline ↑N ↓N counts, so the separate "3 ahead" / "2 behind" badges next to the PR icon were just visual noise. Removes the GitAheadBehindBadges component (now unused) and the gitStatus / isMultiRepo prop chain that fed it. * fix(github): heal legacy repository_id='' rows on github_task_prs A task that pre-dated the multi-repo schema kept its github_task_prs row with repository_id=''. After the schema migration, a subsequent sync would resolve the task's primary repository_id and CreateTaskPR with the new value -- but GetTaskPRByRepository(task, repo) returned nil because the legacy row had an empty repository_id, so the new code inserted a duplicate row instead of updating the legacy one. Net effect: two rows for the same PR, surfacing as a "+2" PR badge on a single-repo task. Adds backfillTaskPRsRepositoryID, run on schema init right after migratePRTablesForMultiRepo: 1. Dedup pass: when a (task, owner, repo, pr_number) tuple has both a legacy (repository_id='') and a per-repo row, drop the legacy one. 2. Backfill pass: any remaining empty-repo rows get stamped with the task's primary repository_id from task_repositories. The backfill is gated on task_repositories existing -- github-store unit tests init only this package's schema, and the dedup pass alone is the load-bearing fix for the symptom. Tests cover the dedup path, the cross-package-skip path, and idempotency. * test(github): cover UPDATE backfill path for orphan legacy rows Adds TestBackfillTaskPRsRepositoryID_BackfillsOrphanRow per claude review feedback. The two prior tests cover the DELETE dedup path and the early-exit when task_repositories is absent, but the UPDATE path that stamps the primary repository_id onto an orphan legacy row had no coverage -- a wrong column name or off-by-one WHERE would have slipped through silently. * feat(web/changes-panel): group PR Changes by repository Multi-repo tasks now have one TaskPR per repo, but the PR Changes section was still rendering files flat using only the primary PR. Adds useActiveTaskPRsWithFiles, which fans out one WS request per TaskPR and tracks each fetch by (owner/repo/pr_number/last_synced_at) so a server-side sync invalidates the cache. Each PR's files are stamped with the resolved repository_name (looked up from the workspace repos slice), and PRFilesGroupedList groups by that name -- mirroring how Commits and Changes already render. Single- repo tasks (one group, empty name) still render flat. Mechanics: - PRChangedFile gains an optional repository_name. - mapPRFilesToChangedFiles takes a repository_name and stamps it. - New PRFilesGroupedList + PRFilesRepoGroup live in changes-panel-pr-files.tsx so changes-panel-timeline.tsx stays under the 600-line cap; PRFilesSection delegates to them. - Hook uses refs (not state) for the in-flight / fetched dedup so setState is only called when results arrive. - Stable EMPTY_PRS sentinel avoids the new-array-each-render trap that would re-trigger every effect downstream. - Workspace repo lookup memoised once via useMemo so the merged file list isn't rebuilt on every render. * fix(web/changes-panel): show per-repo PR header when task is multi-repo Previous logic only stamped repository_name on PR files when prs.length > 1 (2+ PRs). For a multi-repo task with only ONE PR yet (one repo doesn't have a branch / hasn't been pushed), no per-repo header rendered, even though STAGED and COMMITS already grouped under their repo names. Now gates the stamp on the task itself being multi-repo, matching how the other sections decide. * fix(executor): serialise concurrent LaunchPreparedSession per session Two paths in task_operations.go call LaunchPreparedSession on the same session_id: 1. PrepareTaskSession spawns a background goroutine with launchWorkspace= true the moment a session is created (so file browsing works before the user clicks Start). 2. StartTaskWithSession runs a foreground launch when the agent is actually started (workflow auto-start, user click). Without serialisation both reach agent_manager.LaunchAgent in parallel. The fast-path check at the top of LaunchPreparedSession (session.Agent ExecutionID != "") only kicks in once the first call has *persisted* its execution id; the second call grabs a stale snapshot, runs the full env-prep + builds runtime, then loses the executionStore.Add race and fails with "already has an agent running (race resolved during register)". Visible in the UI as "Environment setup failed". Multi-repo amplifies the window because env prep runs sequentially per repo (~2x the time for two repos), so what was a rare flake on single-repo became consistent on multi-repo tasks. Fix: acquire the per-session lock (the same sync.Map used by the resume path) at the entry of LaunchPreparedSession and re-fetch the session under the lock so the fast-path check sees the AgentExecutionID the first holder just persisted. Second caller now takes the existing- workspace path and starts only the agent subprocess, matching the intended single-launch semantics. Adds TestLaunchPreparedSession_SerialisesConcurrentLaunches that fires two concurrent LaunchPreparedSession calls with the first one blocked inside LaunchAgent. Without the lock, both reach LaunchAgent and the test fails with call_count == 2; with the lock only the first does. * fix(orchestrator): per-repo push detection so each repo's PR gets associated Multi-repo tasks would only get one PR detected per task: the agent pushes branches in BOTH repos, but the pushTracker keyed by sessionID alone meant agentctl's per-repo git status events overwrote each other's ahead counts. Whichever repo's events interleaved unfavourably lost its >0 -> 0 transition and silently dropped its push detection. Net effect: only the first/last repo's PR got an associated TaskPR row; the other PR existed on GitHub but Kandev never knew about it. Fix: - pushTracker is now keyed by "|" so each repo's transitions are tracked independently. Empty repository_name preserves the legacy single-repo behaviour. - trackPushAndAssociatePR forwards data.Status.RepositoryName to detectPushAndAssociatePR. - detectPushAndAssociatePR resolves owner/name/repository_id for the specific repo via new resolvePushRepo helper (looks up the task's task_repository whose repository.Name matches the event), then uses GetPRWatchBySessionAndRepo to scope the existing-watch check. - New associatePRFromPushScoped takes the per-repo repository_id directly instead of always resolving the primary one. CheckSessionPR (manual "check now") wired through the same scoped helper. Existing tests pass with the explicit empty repository_name argument. The user's existing task with the missing kandev PR row needs a manual push retrigger or DB heal — the migration only deduped legacy empty rows; this fix prevents the gap from recurring on future tasks. * feat(web): one PR button per repo in topbar + per-PR detail panels PRTopbarButton previously rendered just the primary TaskPR via useActiveTaskPR(), so multi-repo tasks with N PRs only surfaced one. Switch to useTaskPR(activeTaskId).prs and render one button per PR -- each opens its own dockview panel keyed by owner/repo/pr_number. Plumbing: - dockview-store / dockview-panel-actions: addPRPanel(prKey?: string) builds id "pr-detail|" when a key is supplied; the legacy single-repo "pr-detail" id stays for the auto-add path. - dockview-desktop-layout: forwards `params` to PRDetailPanelComponent so the panel can receive its prKey. - pr-detail-panel: new prTaskKey helper + the panel looks up the matching TaskPR from the per-task list when params.prKey is set, falling back to useActiveTaskPR() for legacy panels and tooltips. - pr-topbar-button: rewritten to render a button per PR, each labelled with #pr_number and tooltipped with owner/repo + title; click opens the per-PR panel. * feat(web): collapse multi-PR topbar to a dropdown + show every PR in the + menu Two follow-ups on top of per-PR panels: - The dockview "+" panel-add menu still listed only state.pr (the primary TaskPR), so a 2-repo task only offered "PR #2" -- never the kandev one. Switch to state.prs[] and render one menu item per PR; multi-repo rows are labelled "PR #N -- repo" so the user can tell them apart. Each item opens its own panel via addPRPanel(prTaskKey(pr)). - The topbar grew one outline button per PR after the previous commit, which doesn't scale (3+ PRs would crowd out other actions). Single PR keeps the existing single-button rendering; 2+ collapses to one outline button labelled "N PRs" with the aggregated status colour (red if any failing, etc.) and a dropdown listing each PR with repo, number, title, and per-PR status icon. Click an item -> open that PR's panel. useLeftHeaderState now reads useTaskPR(taskId).prs and forwards the full list through AddPanelMenuState as `prs`. * fix(web/changes-panel): PR files arrive after race-cancelled fetch useActiveTaskPRsWithFiles dropped responses from the previous effect instance via a `cancelled` flag, but since the dedup tracking refs (inFlightRef / fetchedRef) carry across renders, the next effect's early-continue still saw the key in inFlightRef and skipped a fresh request. The ref then got populated by the cancelled response and fetchedRef permanently locked the key out of any future fetch. Net effect: every render after the first prs reference change left the merged prFiles array empty -- the PR Changes section never showed up on multi-repo tasks even though both PRs existed and the WS request would have returned files. Drop the cancellation flag entirely. Per-key dedup is enough to prevent duplicate requests, and the .then handlers use functional setState so they're safe to land after the effect re-runs. * test: cover review-feedback gaps (mapPRFilesToChangedFiles, push multi-repo, lock test channel-sync) Three follow-ups from claude review on PR #767: 1. apps/web/components/task/changes-panel-helpers.test.ts: 5 new tests for mapPRFilesToChangedFiles. Covers status mapping (added / removed / renamed / modified / fallback), forwarding of plus/minus/oldPath, and the new repository_name stamping behaviour (multi-repo path stamps every row; single-repo path defaults to "" so PRFilesGroupedList skips per-repo sub-headers). 2. event_handlers_github_test.go: TestDetectPushAndAssociatePR gains two multi-repo cases. resolvePushRepo must scope CreatePRWatch + AssociatePRWithTask to the repository_id matching the event's repository_name (not silently fall back to primary); unknown repository_name must drop the event rather than mis-associate. mockGitHubService captures lastCreateWatchRepositoryID / lastAssociateRepositoryID so the per-repo scoping is asserted directly. 3. executor_test.go: TestLaunchPreparedSession_SerialisesConcurrentLaunches replaces the time.Sleep(50ms) coordination with a buffered `entered` channel + bounded select wait per CLAUDE.md. The test now waits for the first launch to enter LaunchAgent, then asserts no second entry arrives within a 100ms window — without the lock the second goroutine reaches LaunchAgent in microseconds, so the timeout serves as the "didn't race in" oracle, not a synchronisation primitive. * fix(orchestrator): detect push when first poll lands post-sync trackPushAndAssociatePR only fired on a >0→0 ahead transition, which required two distinct status events to be observed. In multi-repo tasks, any repo whose first agentctl status poll happened to land after the push had already completed (ahead=0 with remote branch set on the very first observation) silently dropped the push event — the watch was never created, the PR never associated. Reproduced on task 4fdff41b-095a-4158-a311-4a1a23abe064 where 2/3 repos pushed but only one PR appeared. Now also fire on first observation when the status already reflects a post-push state (ahead=0 + RemoteBranch set). detectPushAndAssociatePR is idempotent against an existing watch, so a spurious fire on a worktree that started from an already-synced remote just costs one GitHub FindPRByBranch call. The deferred pushTracker.Delete in detectPushAndAssociatePR is removed: keeping ahead=0 stored is what suppresses repeated firing on subsequent status events. Cleanup now happens at session-delete time via a new pushTrackerForget helper that walks all (session|repo) entries. Co-Authored-By: Claude Opus 4.7 * fix(web/topbar): restore pr-topbar-button testid for single-PR case The multi-PR refactor renamed PRSingleButton's testid from pr-topbar-button to pr-topbar-button-${pr_number}, breaking E2E tests that relied on the stable selector (task-topbar-overflow.spec.ts and the prTopbarButton page-object helper used by vcs-button-context). Restore the canonical testid and expose pr_number via data-pr-number for any test that needs to discriminate. PRMultiButton already uses the same testid; with at most one PR-area control rendered at a time, sharing the testid is unambiguous. Co-Authored-By: Claude Opus 4.7 * fix(orchestrator): create one PR watch per repo on multi-repo tasks The orchestrator only ever ensured a watch for a task's PRIMARY repository. Both call sites that create watches were primary-only: - ensureSessionPRWatch (called when a session starts/resumes) used resolvePrimaryTaskRepositoryID and made one EnsurePRWatch call. - buildTaskBranchList (used by the poller's reconciler) keyed dedup by sessionID alone, so as soon as the primary repo got a watch the whole session was marked "watched" and the poller skipped every other repo. Result: in a multi-repo task, the secondary repos never got a watch row, so the periodic GitHub poller never searched for their PRs and never associated them. Push detection (which fires on >0→0 ahead transitions vs origin/main) doesn't help here either because Ahead is measured against the integration branch, not the upstream tracking branch — pushing doesn't change Ahead. Reproduced on task a7a59bd8: PRs existed on github for both kandev and aistats, but only the primary (kandev) was associated. Refactor: - resolveSessionWatchTargets walks task_repositories × session worktrees and returns one (repo_id, owner, repo, branch) target per repo. Branch priority preserves the legacy precedence (checkout_branch > primary fallback > worktree branch) so PR- review-style synthetic worktree names don't clobber the real one. - ensureSessionPRWatch loops over targets and ensures one watch per repo. - buildTaskBranchList emits one TaskBranchInfo per (session, repo) and dedups against (session_id, repository_id) pairs from ListActivePRWatches — replacing the old session-only dedup that suppressed non-primary repos. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- README.md | 1 + apps/backend/cmd/kandev/adapters.go | 41 +- apps/backend/cmd/kandev/gateway.go | 9 +- apps/backend/cmd/kandev/helpers.go | 132 ++- .../internal/agent/handlers/git_handlers.go | 92 +- .../agent/handlers/workspace_file_handlers.go | 35 +- .../internal/agent/lifecycle/env_preparer.go | 67 +- .../agent/lifecycle/env_preparer_worktree.go | 227 +++++ .../env_preparer_worktree_multi_test.go | 327 +++++++ .../internal/agent/lifecycle/event_types.go | 20 + .../internal/agent/lifecycle/events.go | 39 +- .../agent/lifecycle/events_git_status_test.go | 62 ++ .../lifecycle/executor_sprites_e2e_test.go | 6 +- .../agent/lifecycle/manager_launch.go | 24 + .../backend/internal/agent/lifecycle/types.go | 47 + .../agent/lifecycle/types_repospecs_test.go | 92 ++ .../internal/agentctl/client/client_files.go | 44 +- apps/backend/internal/agentctl/client/git.go | 174 +++- .../internal/agentctl/server/api/git.go | 565 +++++++++++- .../server/api/git_log_multi_repo_test.go | 195 +++++ .../internal/agentctl/server/api/server.go | 1 + .../internal/agentctl/server/api/workspace.go | 93 +- .../agentctl/server/api/workspace_state.go | 15 +- .../internal/agentctl/server/process/git.go | 38 +- .../agentctl/server/process/git_log.go | 15 + .../agentctl/server/process/manager.go | 263 +++++- .../process/manager_git_subpath_test.go | 117 +++ .../process/manager_multi_repo_stream_test.go | 140 +++ .../server/process/workspace_files.go | 5 +- .../server/process/workspace_git_commits.go | 21 +- .../server/process/workspace_git_poll.go | 15 +- .../server/process/workspace_git_status.go | 25 +- .../server/process/workspace_monitor.go | 23 +- .../server/process/workspace_stream.go | 40 +- .../server/process/workspace_tracker.go | 102 ++- .../workspace_tracker_multi_repo_test.go | 73 ++ .../internal/agentctl/types/streams/file.go | 32 +- .../internal/agentctl/types/streams/git.go | 21 + apps/backend/internal/github/handlers.go | 14 +- apps/backend/internal/github/models.go | 10 +- apps/backend/internal/github/poller.go | 17 +- apps/backend/internal/github/service.go | 274 ++++-- .../github/service_review_cleanup_test.go | 2 +- apps/backend/internal/github/store.go | 381 ++++++++- .../internal/github/store_multi_repo_test.go | 225 +++++ .../orchestrator/event_handlers_git.go | 126 ++- .../orchestrator/event_handlers_git_test.go | 106 +++ .../orchestrator/event_handlers_github.go | 345 ++++++-- .../event_handlers_github_test.go | 266 +++++- .../orchestrator/executor/executor.go | 43 + .../orchestrator/executor/executor_execute.go | 159 +++- .../executor_execute_workspace_path_test.go | 52 ++ .../executor/executor_mocks_test.go | 89 +- .../executor/executor_multi_repo_test.go | 204 +++++ .../orchestrator/executor/executor_resume.go | 91 +- .../orchestrator/executor/executor_test.go | 96 +++ apps/backend/internal/orchestrator/service.go | 11 +- .../internal/orchestrator/task_operations.go | 3 + .../task/handlers/process_handlers_test.go | 15 + .../task/handlers/repository_handlers.go | 47 +- apps/backend/internal/task/models/models.go | 84 +- .../internal/task/repository/interface.go | 8 +- .../internal/task/repository/sqlite/base.go | 85 ++ .../repository/sqlite/task_environment.go | 192 ++++- .../repository/task_environment_repo_test.go | 289 +++++++ .../task/service/repository_discovery.go | 77 +- .../internal/task/service/service_events.go | 51 +- .../internal/task/service/service_tasks.go | 9 + .../internal/task/service/service_test.go | 52 ++ .../internal/task/service/service_turns.go | 12 +- .../task/service/service_turns_test.go | 50 ++ apps/backend/internal/worktree/manager.go | 175 +++- .../worktree/manager_multi_repo_test.go | 115 +++ .../backend/internal/worktree/manager_test.go | 21 + apps/backend/internal/worktree/store.go | 67 +- apps/web/app/actions/workspaces.ts | 14 +- apps/web/components/combobox.tsx | 75 +- .../components/diff/diff-viewer-resolver.tsx | 3 + apps/web/components/diff/diff-viewer.tsx | 128 ++- apps/web/components/diff/file-diff-viewer.tsx | 4 + .../components/diff/use-diff-viewer-state.ts | 4 + .../components/diff/use-expandable-diff.ts | 22 +- .../web/components/github/pr-detail-panel.tsx | 20 +- .../components/github/pr-task-icon.test.ts | 38 + apps/web/components/github/pr-task-icon.tsx | 72 +- .../components/github/pr-topbar-button.tsx | 98 ++- apps/web/components/kanban-card.tsx | 100 ++- apps/web/components/kanban-column.tsx | 13 +- apps/web/components/review/review-dialog.tsx | 159 +++- .../review/review-diff-list-grouping.test.tsx | 122 +++ .../review/review-diff-list-groups.tsx | 24 + .../components/review/review-diff-list.tsx | 276 ++---- .../components/review/review-diff-toolbar.tsx | 148 ++++ .../components/review/review-file-tree.tsx | 47 +- .../review/types.multi-repo.test.ts | 142 ++++ apps/web/components/review/types.ts | 94 +- ...sk-create-dialog-create-mode-selectors.tsx | 51 ++ .../components/task-create-dialog-effects.ts | 176 ++-- .../task-create-dialog-footer.test.ts | 8 +- .../components/task-create-dialog-footer.tsx | 11 +- .../task-create-dialog-form-body.tsx | 115 +-- .../components/task-create-dialog-handlers.ts | 100 ++- .../components/task-create-dialog-header.tsx | 163 +--- ...k-create-dialog-helpers.multi-repo.test.ts | 82 ++ .../components/task-create-dialog-helpers.ts | 71 +- .../task-create-dialog-locked-fields.ts | 25 +- .../components/task-create-dialog-options.tsx | 18 +- .../task-create-dialog-repo-chips.test.tsx | 316 +++++++ .../task-create-dialog-repo-chips.tsx | 609 +++++++++++++ .../task-create-dialog-repositories-state.ts | 33 + .../task-create-dialog-selectors.tsx | 8 +- .../components/task-create-dialog-setup.tsx | 29 +- .../components/task-create-dialog-state.ts | 124 +-- .../components/task-create-dialog-submit.tsx | 37 +- .../components/task-create-dialog-types.ts | 60 +- apps/web/components/task-create-dialog.tsx | 804 +++++++++++------- .../task/changes-panel-file-row.tsx | 35 +- .../components/task/changes-panel-header.tsx | 281 ++++-- .../task/changes-panel-helpers.test.ts | 70 ++ .../components/task/changes-panel-helpers.ts | 205 +++++ .../components/task/changes-panel-hooks.ts | 197 +++-- .../task/changes-panel-pr-files.tsx | 126 +++ .../task/changes-panel-repo-groups.tsx | 263 ++++++ .../changes-panel-timeline-grouping.test.tsx | 230 +++++ .../task/changes-panel-timeline.tsx | 402 +++++---- apps/web/components/task/changes-panel.tsx | 571 ++++++------- .../components/task/chat/chat-input-area.tsx | 16 +- .../components/task/commit-detail-panel.tsx | 8 +- .../components/task/commit-diff-request.ts | 7 +- apps/web/components/task/commit-row.tsx | 56 +- .../task/dockview-add-panel-items.tsx | 18 +- .../task/dockview-desktop-layout.tsx | 6 +- .../task/dockview-header-actions.tsx | 6 +- .../components/task/dockview-session-tabs.ts | 2 +- .../session-mobile-top-bar-git-controls.tsx | 6 +- .../components/task/task-changes-panel.tsx | 55 +- .../components/task/task-files-panel-hooks.ts | 46 +- apps/web/components/task/task-item.tsx | 4 +- .../task/task-page-content-helpers.ts | 6 + .../web/components/task/task-page-content.tsx | 1 + .../components/task/task-select-helpers.ts | 2 +- .../components/task/task-session-sidebar.tsx | 7 +- apps/web/components/task/task-top-bar.tsx | 48 +- apps/web/components/task/use-review-dialog.ts | 36 +- apps/web/components/vcs-multi-repo-menu.tsx | 300 +++++++ apps/web/components/vcs-split-button.tsx | 156 +++- apps/web/components/vcs/vcs-dialogs.tsx | 257 ++++-- .../e2e/tests/git/git-changes-panel.spec.ts | 27 +- .../task/create-task-branch-selector.spec.ts | 24 +- .../tests/task/create-task-github-url.spec.ts | 2 +- apps/web/e2e/tests/task/create-task.spec.ts | 11 +- .../task/repository-selector-scroll.spec.ts | 6 +- .../github/use-active-task-pr-files.ts | 124 +++ apps/web/hooks/domains/github/use-task-pr.ts | 54 +- apps/web/hooks/domains/kanban/index.ts | 1 + .../hooks/domains/kanban/use-kanban-data.ts | 1 + .../domains/kanban/use-task-repositories.ts | 41 + .../session/use-base-branch-by-repo.ts | 32 + .../domains/session/use-repo-display-name.ts | 69 ++ .../session/use-session-git-grouping.test.ts | 31 + .../domains/session/use-session-git-status.ts | 33 +- .../hooks/domains/session/use-session-git.ts | 701 +++++++++++++-- .../workspace/use-repository-branches.ts | 150 ++-- apps/web/hooks/use-git-operations.ts | 107 ++- apps/web/lib/api/domains/workspace-api.ts | 28 + apps/web/lib/diff/types.ts | 2 +- apps/web/lib/group-by-repo.test.ts | 29 + apps/web/lib/group-by-repo.ts | 26 + apps/web/lib/kanban/map-task.test.ts | 5 +- apps/web/lib/kanban/map-task.ts | 22 +- apps/web/lib/ssr/mapper.ts | 11 +- apps/web/lib/state/dockview-panel-actions.ts | 19 +- apps/web/lib/state/dockview-store.ts | 8 +- .../state/slices/comments/comments-store.ts | 13 +- .../slices/comments/store-multi-repo.test.ts | 66 ++ apps/web/lib/state/slices/comments/types.ts | 14 +- .../lib/state/slices/github/github-slice.ts | 10 +- apps/web/lib/state/slices/github/types.ts | 5 +- apps/web/lib/state/slices/kanban/types.ts | 13 + .../git-status-multi-repo.test.ts | 104 +++ .../session-runtime/session-runtime-slice.ts | 19 +- .../lib/state/slices/session-runtime/types.ts | 25 +- apps/web/lib/state/store.ts | 2 +- apps/web/lib/types/git-events.ts | 8 + apps/web/lib/types/github.ts | 6 +- apps/web/lib/types/http.test.ts | 36 + apps/web/lib/types/http.ts | 22 + apps/web/lib/ws/handlers/git-status.ts | 5 + apps/web/lib/ws/workspace-files.ts | 37 +- 189 files changed, 14086 insertions(+), 2830 deletions(-) create mode 100644 apps/backend/internal/agent/lifecycle/env_preparer_worktree_multi_test.go create mode 100644 apps/backend/internal/agent/lifecycle/events_git_status_test.go create mode 100644 apps/backend/internal/agent/lifecycle/types_repospecs_test.go create mode 100644 apps/backend/internal/agentctl/server/api/git_log_multi_repo_test.go create mode 100644 apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go create mode 100644 apps/backend/internal/agentctl/server/process/manager_multi_repo_stream_test.go create mode 100644 apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go create mode 100644 apps/backend/internal/github/store_multi_repo_test.go create mode 100644 apps/backend/internal/orchestrator/executor/executor_execute_workspace_path_test.go create mode 100644 apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go create mode 100644 apps/backend/internal/task/repository/task_environment_repo_test.go create mode 100644 apps/backend/internal/worktree/manager_multi_repo_test.go create mode 100644 apps/web/components/review/review-diff-list-grouping.test.tsx create mode 100644 apps/web/components/review/review-diff-list-groups.tsx create mode 100644 apps/web/components/review/review-diff-toolbar.tsx create mode 100644 apps/web/components/review/types.multi-repo.test.ts create mode 100644 apps/web/components/task-create-dialog-create-mode-selectors.tsx create mode 100644 apps/web/components/task-create-dialog-helpers.multi-repo.test.ts create mode 100644 apps/web/components/task-create-dialog-repo-chips.test.tsx create mode 100644 apps/web/components/task-create-dialog-repo-chips.tsx create mode 100644 apps/web/components/task-create-dialog-repositories-state.ts create mode 100644 apps/web/components/task/changes-panel-helpers.test.ts create mode 100644 apps/web/components/task/changes-panel-helpers.ts create mode 100644 apps/web/components/task/changes-panel-pr-files.tsx create mode 100644 apps/web/components/task/changes-panel-repo-groups.tsx create mode 100644 apps/web/components/task/changes-panel-timeline-grouping.test.tsx create mode 100644 apps/web/components/vcs-multi-repo-menu.tsx create mode 100644 apps/web/hooks/domains/github/use-active-task-pr-files.ts create mode 100644 apps/web/hooks/domains/kanban/use-task-repositories.ts create mode 100644 apps/web/hooks/domains/session/use-base-branch-by-repo.ts create mode 100644 apps/web/hooks/domains/session/use-repo-display-name.ts create mode 100644 apps/web/hooks/domains/session/use-session-git-grouping.test.ts create mode 100644 apps/web/lib/group-by-repo.test.ts create mode 100644 apps/web/lib/group-by-repo.ts create mode 100644 apps/web/lib/state/slices/comments/store-multi-repo.test.ts create mode 100644 apps/web/lib/state/slices/session-runtime/git-status-multi-repo.test.ts create mode 100644 apps/web/lib/types/http.test.ts diff --git a/README.md b/README.md index 7e626d8978..a9308f6d57 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Open source, multi-provider, no telemetry, not tied to any cloud. - **Sub-tasks** - Agents can spawn sub-tasks that resume from the parent task's session. Useful for splitting a task that has grown too big, or producing several PRs from the same starting point. - **CLI passthrough** - Drop into raw agent CLI mode for direct terminal interaction with any supported agent, leverage the full power of their TUIs - **Workspace isolation** - Git worktrees prevent concurrent agents from conflicting +- **Multi-repository tasks** - Span a single task across multiple repositories, with one worktree per repo, per-repo branches, per-repo PRs, and per-repo grouping in the Changes panel and review dialog - **Flexible runtimes** - Run agents as local processes, in isolated Docker containers or in remote executors like sprites.dev - **Session management** - Resume and review agent conversations - **Stats** - Track your productivity with stats on the completed tasks, agent turns, etc diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index d89c4e701c..5bddee1aa7 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -101,6 +101,27 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch RepoName: req.RepoName, } + // Multi-repo: forward the explicit repo list when the orchestrator built one. + if len(req.Repositories) > 0 { + specs := make([]lifecycle.RepoLaunchSpec, 0, len(req.Repositories)) + for _, r := range req.Repositories { + specs = append(specs, lifecycle.RepoLaunchSpec{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepositoryURL: r.RepositoryURL, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: r.RepoSetupScript, + RepoCleanupScript: r.RepoCleanupScript, + }) + } + launchReq.Repositories = specs + } + // Create the agentctl execution (does NOT start agent process) execution, err := a.mgr.Launch(ctx, launchReq) if err != nil { @@ -121,6 +142,23 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch } } + // Surface per-repo worktree results from the prepare step so the orchestrator + // can persist N TaskEnvironmentRepo / TaskSessionWorktree rows when multi-repo. + var worktrees []executor.RepoWorktreeResult + if execution.PrepareResult != nil && len(execution.PrepareResult.Worktrees) > 0 { + worktrees = make([]executor.RepoWorktreeResult, 0, len(execution.PrepareResult.Worktrees)) + for _, w := range execution.PrepareResult.Worktrees { + worktrees = append(worktrees, executor.RepoWorktreeResult{ + RepositoryID: w.RepositoryID, + WorktreeID: w.WorktreeID, + WorktreeBranch: w.WorktreeBranch, + WorktreePath: w.WorktreePath, + MainRepoGitDir: w.MainRepoGitDir, + ErrorMessage: w.ErrorMessage, + }) + } + } + return &executor.LaunchAgentResponse{ AgentExecutionID: execution.ID, ContainerID: execution.ContainerID, @@ -130,6 +168,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch WorktreeBranch: worktreeBranch, Metadata: execution.Metadata, PrepareResult: execution.PrepareResult, + Worktrees: worktrees, }, nil } @@ -354,7 +393,7 @@ func (a *lifecycleAdapter) GetGitLog(ctx context.Context, sessionID, baseCommit if agentClient == nil { return nil, nil } - return agentClient.GitLog(ctx, baseCommit, limit, targetBranch) + return agentClient.GitLog(ctx, baseCommit, limit, targetBranch, "") } // GetCumulativeDiff retrieves the cumulative diff for a session from baseCommit to HEAD. diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index 1f3256c0b9..f67653b541 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -143,8 +143,13 @@ func provideGateway( gitHandlers := agenthandlers.NewGitHandlers(lifecycleMgr, &sessionReaderAdapter{repo: taskRepo, logger: log}, log) if githubSvc != nil { - gitHandlers.SetOnPRCreated(func(ctx context.Context, sessionID, taskID, prURL, branch string) { - githubSvc.AssociatePRByURL(ctx, sessionID, taskID, prURL, branch) + gitHandlers.SetOnPRCreated(func(ctx context.Context, sessionID, taskID, prURL, branch, repo string) { + // Resolve the multi-repo subpath to a per-task repository_id so the + // resulting TaskPR / PRWatch rows are scoped per-repo. Empty `repo` + // (single-repo task) yields empty repositoryID and preserves legacy + // 1:1 task→PR semantics. + repositoryID := resolveRepositoryIDForSubpath(ctx, taskRepo, taskID, repo, log) + githubSvc.AssociatePRByURL(ctx, sessionID, taskID, repositoryID, prURL, branch) }) } gitHandlers.SetOnGitOperationFailed(func(ctx context.Context, sessionID, taskID, operation, errorOutput string) { diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 9448132f4b..3c62ae128d 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -23,6 +23,7 @@ import ( "github.com/kandev/kandev/internal/agent/mcpconfig" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" + "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/tracing" analyticshandlers "github.com/kandev/kandev/internal/analytics/handlers" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" @@ -159,21 +160,22 @@ func appendSessionStateMessage(sessionID string, session *models.TaskSession, re return result } -// appendLiveGitStatusMessage adds a git status notification by querying agentctl for live status. -// Falls back to DB snapshot if no execution exists (for archived sessions). +// appendLiveGitStatusMessage adds git status notification(s) by querying +// agentctl for live status. Multi-repo workspaces emit one notification per +// repo (stamped with repository_name); single-repo emits a single untagged +// notification. Falls back to DB snapshot if no execution exists (archived +// sessions only — the snapshot is workspace-wide, not per-repo). func appendLiveGitStatusMessage(ctx context.Context, taskRepo *sqliterepo.Repository, lifecycleMgr *lifecycle.Manager, sessionID string, session *models.TaskSession, result []*ws.Message, log *logger.Logger) []*ws.Message { - // Try to get live git status from agentctl - if msg := tryGetLiveGitStatus(ctx, lifecycleMgr, sessionID, log); msg != nil { - return append(result, msg) + if msgs := tryGetLiveGitStatus(ctx, lifecycleMgr, sessionID, log); len(msgs) > 0 { + return append(result, msgs...) } - - // Fallback: try to load from DB snapshot (for archived sessions) return appendDBSnapshotGitStatus(ctx, taskRepo, sessionID, result, log) } // tryGetLiveGitStatus attempts to get live git status from agentctl. -// Returns a notification message if successful, nil otherwise. -func tryGetLiveGitStatus(ctx context.Context, lifecycleMgr *lifecycle.Manager, sessionID string, log *logger.Logger) *ws.Message { +// Returns one notification per repo (one entry for single-repo workspaces). +// Returns nil when the session has no live execution or agentctl is stuck. +func tryGetLiveGitStatus(ctx context.Context, lifecycleMgr *lifecycle.Manager, sessionID string, log *logger.Logger) []*ws.Message { if lifecycleMgr == nil { return nil } @@ -195,41 +197,63 @@ func tryGetLiveGitStatus(ctx context.Context, lifecycleMgr *lifecycle.Manager, s // Use bounded timeout to prevent blocking session hydration if agentctl is stuck. rpcCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() - status, err := agentClient.GetGitStatus(rpcCtx) + multi, err := agentClient.GetGitStatusMulti(rpcCtx) if err != nil { log.Debug("failed to get live git status, will fall back to DB snapshot", zap.String("session_id", sessionID), zap.Error(err)) return nil } - - if !status.Success { + if multi == nil || !multi.Success || len(multi.Repos) == 0 { return nil } + out := make([]*ws.Message, 0, len(multi.Repos)) + for _, repo := range multi.Repos { + if !repo.Status.Success { + continue + } + notification := buildGitStatusNotification(sessionID, repo.RepositoryName, repo.Status) + if notification != nil { + out = append(out, notification) + } + } + if len(out) == 0 { + return nil + } log.Debug("got live git status from agentctl", zap.String("session_id", sessionID), - zap.String("branch", status.Branch), - zap.Int("files_count", len(status.Files))) + zap.Int("repos", len(out))) + return out +} +// buildGitStatusNotification packages a single repo's status as a WS event +// the frontend can route through its existing git-status handler. The +// repository_name is stamped on the inner status payload so the frontend +// stores it under byEnvironmentRepo[envKey][repository_name]. +func buildGitStatusNotification(sessionID, repositoryName string, status client.GitStatusResult) *ws.Message { + statusPayload := map[string]interface{}{ + "branch": status.Branch, + "remote_branch": status.RemoteBranch, + "ahead": status.Ahead, + "behind": status.Behind, + "files": status.Files, + "modified": status.Modified, + "added": status.Added, + "deleted": status.Deleted, + "untracked": status.Untracked, + "renamed": status.Renamed, + "branch_additions": status.BranchAdditions, + "branch_deletions": status.BranchDeletions, + } + if repositoryName != "" { + statusPayload["repository_name"] = repositoryName + } gitEventData := map[string]interface{}{ "type": "status_update", "session_id": sessionID, "timestamp": status.Timestamp, - "status": map[string]interface{}{ - "branch": status.Branch, - "remote_branch": status.RemoteBranch, - "ahead": status.Ahead, - "behind": status.Behind, - "files": status.Files, - "modified": status.Modified, - "added": status.Added, - "deleted": status.Deleted, - "untracked": status.Untracked, - "renamed": status.Renamed, - "branch_additions": status.BranchAdditions, - "branch_deletions": status.BranchDeletions, - }, + "status": statusPayload, } notification, err := ws.NewNotification(ws.ActionSessionGitEvent, gitEventData) if err != nil { @@ -461,6 +485,52 @@ func registerRoutes(p routeParams) { } } +// resolvePrimaryTaskRepositoryID returns the primary (lowest-position) +// task_repositories.repository_id for a task, or "" if none / on error. +// Used by PR-import callbacks where the PR is associated with the task's +// primary repo (the one the task was created against). +func resolvePrimaryTaskRepositoryID(ctx context.Context, taskRepo *sqliterepo.Repository, taskID string, log *logger.Logger) string { + repo, err := taskRepo.GetPrimaryTaskRepository(ctx, taskID) + if err != nil { + log.Warn("primary task repository lookup failed", + zap.String("task_id", taskID), zap.Error(err)) + return "" + } + if repo == nil { + return "" + } + return repo.RepositoryID +} + +// resolveRepositoryIDForSubpath maps a multi-repo subpath name (e.g. +// "kandev") to its task_repositories.repository_id by joining with the +// repositories table on Name. Empty subpath falls back to the primary +// repository so single-repo tasks Just Work. Returns "" if no match — the +// caller will then write a legacy single-repo PR row. +func resolveRepositoryIDForSubpath(ctx context.Context, taskRepo *sqliterepo.Repository, taskID, subpath string, log *logger.Logger) string { + if subpath == "" { + return resolvePrimaryTaskRepositoryID(ctx, taskRepo, taskID, log) + } + repos, err := taskRepo.ListTaskRepositories(ctx, taskID) + if err != nil { + log.Warn("task repositories lookup failed", + zap.String("task_id", taskID), zap.Error(err)) + return "" + } + for _, link := range repos { + repo, err := taskRepo.GetRepository(ctx, link.RepositoryID) + if err != nil || repo == nil { + continue + } + if repo.Name == subpath { + return link.RepositoryID + } + } + log.Warn("no task repository matches subpath", + zap.String("task_id", taskID), zap.String("subpath", subpath)) + return "" +} + // registerTaskRoutes registers all task-related HTTP and WebSocket routes. func registerTaskRoutes(p routeParams, planService *taskservice.PlanService) { taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) @@ -469,7 +539,11 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService) { if p.services.GitHub != nil { ghSvc := p.services.GitHub taskH.SetOnTaskCreatedWithPR(func(ctx context.Context, taskID, sessionID, prURL, branch string) { - ghSvc.AssociatePRByURL(ctx, sessionID, taskID, prURL, branch) + // Task-create-from-PR runs once per task and the PR maps to the + // primary repository (first task_repository row). Resolve to that + // repository_id so the resulting TaskPR/PRWatch are scoped per-repo. + repositoryID := resolvePrimaryTaskRepositoryID(ctx, p.taskRepo, taskID, p.log) + ghSvc.AssociatePRByURL(ctx, sessionID, taskID, repositoryID, prURL, branch) }) } taskhandlers.RegisterRepositoryRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index b580beca39..3fae5190fb 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -15,9 +15,12 @@ import ( "go.uber.org/zap" ) -// PRCreatedCallback is called after a PR is successfully created. +// PRCreatedCallback is called after a PR is successfully created. `repo` is +// the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +// The callback uses it to scope the resulting TaskPR / PRWatch rows to the +// owning repository so the second repo's PR doesn't overwrite the first. // Parameters: ctx, sessionID, taskID, prURL, branch. -type PRCreatedCallback func(ctx context.Context, sessionID, taskID, prURL, branch string) +type PRCreatedCallback func(ctx context.Context, sessionID, taskID, prURL, branch, repo string) // GitOperationFailedCallback is called when a git operation fails. // Parameters: ctx, sessionID, taskID, operation name, error output. @@ -116,35 +119,45 @@ func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionSessionCumulativeDiff, h.wsCumulativeDiff) } -// GitPullRequest for worktree.pull action +// GitPullRequest for worktree.pull action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitPullRequest struct { SessionID string `json:"session_id"` Rebase bool `json:"rebase"` + Repo string `json:"repo,omitempty"` } -// GitPushRequest for worktree.push action +// GitPushRequest for worktree.push action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitPushRequest struct { SessionID string `json:"session_id"` Force bool `json:"force"` SetUpstream bool `json:"set_upstream"` + Repo string `json:"repo,omitempty"` } -// GitRebaseRequest for worktree.rebase action +// GitRebaseRequest for worktree.rebase action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitRebaseRequest struct { SessionID string `json:"session_id"` BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` } -// GitMergeRequest for worktree.merge action +// GitMergeRequest for worktree.merge action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitMergeRequest struct { SessionID string `json:"session_id"` BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` } -// GitAbortRequest for worktree.abort action +// GitAbortRequest for worktree.abort action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitAbortRequest struct { SessionID string `json:"session_id"` Operation string `json:"operation"` // "merge" or "rebase" + Repo string `json:"repo,omitempty"` } // GitCommitRequest for worktree.commit action @@ -153,58 +166,74 @@ type GitCommitRequest struct { Message string `json:"message"` StageAll bool `json:"stage_all"` Amend bool `json:"amend"` + // Multi-repo: subpath of the repo to commit in (e.g. "kandev"). Empty for + // single-repo workspaces. Required for multi-repo — committing at the task + // root fails because it isn't itself a git repo. + Repo string `json:"repo,omitempty"` } -// GitRenameBranchRequest for worktree.rename_branch action +// GitRenameBranchRequest for worktree.rename_branch action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo. type GitRenameBranchRequest struct { SessionID string `json:"session_id"` NewName string `json:"new_name"` + Repo string `json:"repo,omitempty"` } // GitStageRequest for worktree.stage action type GitStageRequest struct { SessionID string `json:"session_id"` - Paths []string `json:"paths"` // Empty = stage all + Paths []string `json:"paths"` // Empty = stage all + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitUnstageRequest for worktree.unstage action type GitUnstageRequest struct { SessionID string `json:"session_id"` - Paths []string `json:"paths"` // Empty = unstage all + Paths []string `json:"paths"` // Empty = unstage all + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitDiscardRequest for worktree.discard action type GitDiscardRequest struct { SessionID string `json:"session_id"` - Paths []string `json:"paths"` // Required - files to discard + Paths []string `json:"paths"` // Required - files to discard + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } -// GitCreatePRRequest for worktree.create_pr action +// GitCreatePRRequest for worktree.create_pr action. +// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo +// workspaces. Without it, agentctl falls back to the workspace root which +// for multi-repo task workspaces isn't a git repo, so PR creation fails. type GitCreatePRRequest struct { SessionID string `json:"session_id"` Title string `json:"title"` Body string `json:"body"` BaseBranch string `json:"base_branch"` Draft bool `json:"draft"` + Repo string `json:"repo,omitempty"` } // GitRevertCommitRequest for worktree.revert_commit action type GitRevertCommitRequest struct { SessionID string `json:"session_id"` CommitSHA string `json:"commit_sha"` + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitResetRequest for worktree.reset action type GitResetRequest struct { SessionID string `json:"session_id"` CommitSHA string `json:"commit_sha"` - Mode string `json:"mode"` // "soft", "mixed", or "hard" + Mode string `json:"mode"` // "soft", "mixed", or "hard" + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitShowCommitRequest for session.commit_diff action type GitShowCommitRequest struct { SessionID string `json:"session_id"` CommitSHA string `json:"commit_sha"` + Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // wsPull handles worktree.pull action @@ -223,7 +252,7 @@ func (h *GitHandlers) wsPull(ctx context.Context, msg *ws.Message) (*ws.Message, return nil, err } - result, err := client.GitPull(ctx, req.Rebase) + result, err := client.GitPull(ctx, req.Rebase, req.Repo) if err != nil { return nil, fmt.Errorf("pull failed: %w", err) } @@ -248,7 +277,7 @@ func (h *GitHandlers) wsPush(ctx context.Context, msg *ws.Message) (*ws.Message, return nil, err } - result, err := client.GitPush(ctx, req.Force, req.SetUpstream) + result, err := client.GitPush(ctx, req.Force, req.SetUpstream, req.Repo) if err != nil { return nil, fmt.Errorf("push failed: %w", err) } @@ -276,7 +305,7 @@ func (h *GitHandlers) wsRebase(ctx context.Context, msg *ws.Message) (*ws.Messag return nil, err } - result, err := client.GitRebase(ctx, req.BaseBranch) + result, err := client.GitRebase(ctx, req.BaseBranch, req.Repo) if err != nil { return nil, fmt.Errorf("rebase failed: %w", err) } @@ -304,7 +333,7 @@ func (h *GitHandlers) wsMerge(ctx context.Context, msg *ws.Message) (*ws.Message return nil, err } - result, err := client.GitMerge(ctx, req.BaseBranch) + result, err := client.GitMerge(ctx, req.BaseBranch, req.Repo) if err != nil { return nil, fmt.Errorf("merge failed: %w", err) } @@ -332,7 +361,7 @@ func (h *GitHandlers) wsAbort(ctx context.Context, msg *ws.Message) (*ws.Message return nil, err } - result, err := client.GitAbort(ctx, req.Operation) + result, err := client.GitAbort(ctx, req.Operation, req.Repo) if err != nil { return nil, fmt.Errorf("abort failed: %w", err) } @@ -359,7 +388,7 @@ func (h *GitHandlers) wsCommit(ctx context.Context, msg *ws.Message) (*ws.Messag return nil, err } - result, err := client.GitCommit(ctx, req.Message, req.StageAll, req.Amend) + result, err := client.GitCommit(ctx, req.Message, req.StageAll, req.Amend, req.Repo) if err != nil { return nil, fmt.Errorf("commit failed: %w", err) } @@ -387,7 +416,7 @@ func (h *GitHandlers) wsRenameBranch(ctx context.Context, msg *ws.Message) (*ws. return nil, err } - result, err := client.GitRenameBranch(ctx, req.NewName) + result, err := client.GitRenameBranch(ctx, req.NewName, req.Repo) if err != nil { return nil, fmt.Errorf("rename branch failed: %w", err) } @@ -421,7 +450,7 @@ func (h *GitHandlers) wsReset(ctx context.Context, msg *ws.Message) (*ws.Message return nil, err } - result, err := client.GitReset(ctx, req.CommitSHA, req.Mode) + result, err := client.GitReset(ctx, req.CommitSHA, req.Mode, req.Repo) if err != nil { return nil, fmt.Errorf("reset failed: %w", err) } @@ -445,7 +474,7 @@ func (h *GitHandlers) wsStage(ctx context.Context, msg *ws.Message) (*ws.Message return nil, err } - result, err := client.GitStage(ctx, req.Paths) + result, err := client.GitStage(ctx, req.Paths, req.Repo) if err != nil { return nil, fmt.Errorf("stage failed: %w", err) } @@ -469,7 +498,7 @@ func (h *GitHandlers) wsUnstage(ctx context.Context, msg *ws.Message) (*ws.Messa return nil, err } - result, err := client.GitUnstage(ctx, req.Paths) + result, err := client.GitUnstage(ctx, req.Paths, req.Repo) if err != nil { return nil, fmt.Errorf("unstage failed: %w", err) } @@ -497,7 +526,7 @@ func (h *GitHandlers) wsDiscard(ctx context.Context, msg *ws.Message) (*ws.Messa return nil, err } - result, err := client.GitDiscard(ctx, req.Paths) + result, err := client.GitDiscard(ctx, req.Paths, req.Repo) if err != nil { return nil, fmt.Errorf("discard failed: %w", err) } @@ -524,12 +553,14 @@ func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Mess return nil, err } - result, err := client.GitCreatePR(ctx, req.Title, req.Body, req.BaseBranch, req.Draft) + result, err := client.GitCreatePR(ctx, req.Title, req.Body, req.BaseBranch, req.Draft, req.Repo) if err != nil { return nil, fmt.Errorf("create PR failed: %w", err) } - // On success, notify callback to associate PR with task. + // On success, notify callback to associate PR with task. The repo subpath + // flows through so the orchestrator can scope the resulting TaskPR / + // PRWatch rows to the per-task repository_id. // Use a timeout-bound context so a stuck callback doesn't leak the goroutine. if result.Success && result.PRURL != "" && h.onPRCreated != nil { execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) @@ -537,10 +568,11 @@ func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Mess sessionID := req.SessionID taskID := execution.TaskID prURL := result.PRURL + repo := req.Repo go func() { callbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - h.onPRCreated(callbackCtx, sessionID, taskID, prURL, "") + h.onPRCreated(callbackCtx, sessionID, taskID, prURL, "", repo) }() } } @@ -567,7 +599,7 @@ func (h *GitHandlers) wsRevertCommit(ctx context.Context, msg *ws.Message) (*ws. return nil, err } - result, err := client.GitRevertCommit(ctx, req.CommitSHA) + result, err := client.GitRevertCommit(ctx, req.CommitSHA, req.Repo) if err != nil { return nil, fmt.Errorf("revert commit failed: %w", err) } @@ -603,7 +635,7 @@ func (h *GitHandlers) wsCommitDiff(ctx context.Context, msg *ws.Message) (*ws.Me return nil, err } - result, err := client.GitShowCommit(ctx, req.CommitSHA) + result, err := client.GitShowCommit(ctx, req.CommitSHA, req.Repo) if err != nil { return nil, fmt.Errorf("show commit failed: %w", err) } @@ -702,7 +734,7 @@ func (h *GitHandlers) wsGitCommits(ctx context.Context, msg *ws.Message) (*ws.Me // Use target branch for dynamic merge-base calculation. // This ensures accurate commit filtering even after rebases. - result, err := agentClient.GitLog(ctx, baseCommit, req.Limit, targetBranch) + result, err := agentClient.GitLog(ctx, baseCommit, req.Limit, targetBranch, "") if err != nil { return nil, fmt.Errorf("git log failed: %w", err) } diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index 804cba4ec7..5c3d2f59fb 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -80,6 +80,7 @@ func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Me var req struct { SessionID string `json:"session_id"` Path string `json:"path"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { @@ -106,7 +107,7 @@ func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Me } // Request file content from agentctl - response, err := client.RequestFileContent(ctx, req.Path) + response, err := client.RequestFileContent(ctx, req.Path, req.Repo) if err != nil { h.logger.Error("failed to get file content", zap.Error(err), zap.String("session_id", req.SessionID), zap.String("path", req.Path)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to get file content: %v", err), nil) @@ -121,6 +122,7 @@ func (h *WorkspaceFileHandlers) wsGetFileContentAtRef(ctx context.Context, msg * SessionID string `json:"session_id"` Path string `json:"path"` Ref string `json:"ref"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { @@ -150,7 +152,7 @@ func (h *WorkspaceFileHandlers) wsGetFileContentAtRef(ctx context.Context, msg * } // Request file content at ref from agentctl - response, err := client.RequestFileContentAtRef(ctx, req.Path, req.Ref) + response, err := client.RequestFileContentAtRef(ctx, req.Path, req.Ref, req.Repo) if err != nil { // "File not found at ref" is expected for new files - log as debug, not error errMsg := err.Error() @@ -173,6 +175,7 @@ func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws Diff string `json:"diff"` OriginalHash string `json:"original_hash"` DesiredContent *string `json:"desired_content"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { @@ -202,7 +205,7 @@ func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws } // Apply file diff via agentctl - response, err := client.ApplyFileDiff(ctx, req.Path, req.Diff, req.OriginalHash, req.DesiredContent) + response, err := client.ApplyFileDiff(ctx, req.Path, req.Diff, req.OriginalHash, req.Repo, req.DesiredContent) if err != nil { h.logger.Error("failed to apply file diff", zap.Error(err), zap.String("session_id", req.SessionID), zap.String("path", req.Path)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to apply file diff: %v", err), nil) @@ -215,46 +218,47 @@ func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws // and returns the agentctl client, or an error response. func (h *WorkspaceFileHandlers) resolveSessionFileClient( ctx context.Context, msg *ws.Message, -) (sessionID, path string, client *agentctl.Client, errResp *ws.Message) { +) (sessionID, path, repo string, client *agentctl.Client, errResp *ws.Message) { var req struct { SessionID string `json:"session_id"` Path string `json:"path"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } if req.SessionID == "" { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } if req.Path == "" { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "path is required", nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } execution, resolveErr := h.lifecycle.GetOrEnsureExecution(ctx, req.SessionID) if resolveErr != nil { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for session: "+resolveErr.Error(), nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } c := execution.GetAgentCtlClient() if c == nil { errResp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent client not available", nil) - return "", "", nil, errResp + return "", "", "", nil, errResp } - return req.SessionID, req.Path, c, nil + return req.SessionID, req.Path, req.Repo, c, nil } // wsCreateFile handles workspace.file.create action func (h *WorkspaceFileHandlers) wsCreateFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - sessionID, path, client, errResp := h.resolveSessionFileClient(ctx, msg) + sessionID, path, repo, client, errResp := h.resolveSessionFileClient(ctx, msg) if errResp != nil { return errResp, nil } - response, err := client.CreateFile(ctx, path) + response, err := client.CreateFile(ctx, path, repo) if err != nil { h.logger.Error("failed to create file", zap.Error(err), zap.String("session_id", sessionID), zap.String("path", path)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to create file: %v", err), nil) @@ -265,12 +269,12 @@ func (h *WorkspaceFileHandlers) wsCreateFile(ctx context.Context, msg *ws.Messag // wsDeleteFile handles workspace.file.delete action func (h *WorkspaceFileHandlers) wsDeleteFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - sessionID, path, client, errResp := h.resolveSessionFileClient(ctx, msg) + sessionID, path, repo, client, errResp := h.resolveSessionFileClient(ctx, msg) if errResp != nil { return errResp, nil } - response, err := client.DeleteFile(ctx, path) + response, err := client.DeleteFile(ctx, path, repo) if err != nil { h.logger.Error("failed to delete file", zap.Error(err), zap.String("session_id", sessionID), zap.String("path", path)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to delete file: %v", err), nil) @@ -329,6 +333,7 @@ func (h *WorkspaceFileHandlers) wsRenameFile(ctx context.Context, msg *ws.Messag SessionID string `json:"session_id"` OldPath string `json:"old_path"` NewPath string `json:"new_path"` + Repo string `json:"repo,omitempty"` } if err := msg.ParsePayload(&req); err != nil { @@ -358,7 +363,7 @@ func (h *WorkspaceFileHandlers) wsRenameFile(ctx context.Context, msg *ws.Messag } // Rename file via agentctl - response, err := client.RenameFile(ctx, req.OldPath, req.NewPath) + response, err := client.RenameFile(ctx, req.OldPath, req.NewPath, req.Repo) if err != nil { h.logger.Error("failed to rename file", zap.Error(err), zap.String("session_id", req.SessionID), diff --git a/apps/backend/internal/agent/lifecycle/env_preparer.go b/apps/backend/internal/agent/lifecycle/env_preparer.go index edee188546..e9b7a5b6d0 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer.go @@ -20,6 +20,23 @@ const ( PrepareStepSkipped PrepareStepStatus = "skipped" ) +// RepoPrepareSpec describes one repository for multi-repo environment preparation. +// Mirrors the per-repo prepare fields that EnvPrepareRequest historically +// carried at the top level. When EnvPrepareRequest.Repositories is non-empty, +// each entry produces one prepared worktree under the shared TaskDirName. +type RepoPrepareSpec struct { + RepositoryID string + RepositoryPath string + RepoName string + BaseBranch string + CheckoutBranch string + WorktreeID string + WorktreeBranch string + WorktreeBranchPrefix string + PullBeforeWorktree bool + RepoSetupScript string +} + // EnvPrepareRequest contains the parameters for environment preparation. type EnvPrepareRequest struct { TaskID string @@ -44,9 +61,40 @@ type EnvPrepareRequest struct { TaskDirName string // Per-task directory name within the workspace (e.g. "task-abc123") RepoName string // Repository slug used with TaskDirName to locate checkouts + // Repositories carries one entry per repository when the request is + // multi-repo. When non-empty it is the source of truth; the legacy + // single-repo top-level fields above are populated from Repositories[0] + // for callers that have not yet been updated. + Repositories []RepoPrepareSpec + Env map[string]string } +// RepoSpecs returns the per-repo prepare specs for this request. When +// Repositories is set it is returned verbatim; otherwise a length-1 list is +// synthesized from the legacy top-level single-repo fields. Returns an empty +// slice for repo-less requests. +func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec { + if len(r.Repositories) > 0 { + return r.Repositories + } + if r.RepositoryID == "" && r.RepositoryPath == "" { + return nil + } + return []RepoPrepareSpec{{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranch: r.WorktreeBranch, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: r.RepoSetupScript, + }} +} + // PrepareStep represents a single step in the preparation process. type PrepareStep struct { Name string `json:"name"` @@ -60,6 +108,18 @@ type PrepareStep struct { EndedAt *time.Time `json:"ended_at,omitempty"` } +// RepoWorktreeResult is the per-repository outcome of environment preparation. +// Populated by preparers that handle multi-repo launches; each entry corresponds +// to one RepoPrepareSpec from the request. +type RepoWorktreeResult struct { + RepositoryID string `json:"repository_id"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + // EnvPrepareResult contains the result of environment preparation. type EnvPrepareResult struct { Success bool `json:"success"` @@ -68,10 +128,15 @@ type EnvPrepareResult struct { ErrorMessage string `json:"error_message,omitempty"` Duration time.Duration `json:"duration"` - // Worktree fields (populated when worktree preparer runs) + // Worktree fields (populated when worktree preparer runs). + // Legacy single-worktree fields; for multi-repo results they mirror Worktrees[0]. WorktreeID string `json:"worktree_id,omitempty"` WorktreeBranch string `json:"worktree_branch,omitempty"` MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` + + // Worktrees is the per-repository outcome list when the preparer ran in + // multi-repo mode. Empty for single-repo or repo-less results. + Worktrees []RepoWorktreeResult `json:"worktrees,omitempty"` } // PrepareProgressCallback is called when a preparation step changes status. diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go index 2168e2d438..a0ef0b8a8d 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "time" "go.uber.org/zap" @@ -30,6 +31,13 @@ func NewWorktreePreparer(worktreeMgr *worktree.Manager, log *logger.Logger) *Wor func (p *WorktreePreparer) Name() string { return "worktree" } func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) { + // Multi-repo (>=2 explicit specs) takes a dedicated path that creates one + // worktree per repo and rolls back on partial failure. Single-repo and + // repo-less requests use the original sequence unchanged. + if specs := req.Repositories; len(specs) >= 2 { + return p.prepareMultiRepo(ctx, req, specs, onProgress) + } + start := time.Now() var steps []PrepareStep @@ -211,6 +219,225 @@ func finalizeSyncStep( return steps } +// prepareMultiRepo prepares N worktrees, one per spec. On any per-repo +// failure, all already-created worktrees for this preparation are rolled back +// and the request is reported as failed with the originating error. +// +// Per-repo setup scripts (RepoSetupScript) run as a separate step after each +// repo's worktree is created. The task-level setup script (req.SetupScript) +// is intentionally NOT run from this path — that resolution depends on the +// task root layout and is handled by the caller in a future phase. +// +// On success the legacy single-worktree fields on EnvPrepareResult mirror +// Worktrees[0] so existing downstream code (manager_launch.launchApplyPrepareResult, +// orchestrator persistence) continues to consume the first repo's worktree +// without changes. +func (p *WorktreePreparer) prepareMultiRepo( + ctx context.Context, + req *EnvPrepareRequest, + specs []RepoPrepareSpec, + onProgress PrepareProgressCallback, +) (*EnvPrepareResult, error) { + start := time.Now() + var steps []PrepareStep + + if p.worktreeMgr == nil { + step := beginStep("Validate repositories") + completeStepError(&step, "worktree manager not available") + steps = append(steps, step) + reportProgress(onProgress, step, 0, 1) + return &EnvPrepareResult{ + Success: false, + Steps: steps, + ErrorMessage: step.Error, + Duration: time.Since(start), + }, nil + } + + // Step budget: per-repo (validate + create + optional sync + optional checkout + optional setup script). + totalSteps := 0 + for _, spec := range specs { + totalSteps += 2 + if spec.PullBeforeWorktree { + totalSteps++ + } + if spec.CheckoutBranch != "" { + totalSteps++ + } + if strings.TrimSpace(spec.RepoSetupScript) != "" { + totalSteps++ + } + } + + stepIdx := 0 + worktrees := make([]RepoWorktreeResult, 0, len(specs)) + createdIDs := make([]string, 0, len(specs)) + + for _, spec := range specs { + wt, newSteps, nextIdx, err := p.prepareOneRepo(ctx, req, spec, stepIdx, totalSteps, onProgress, steps) + steps = newSteps + stepIdx = nextIdx + if err != nil { + p.rollbackWorktrees(ctx, createdIDs) + return &EnvPrepareResult{ + Success: false, + Steps: steps, + ErrorMessage: err.Error(), + Duration: time.Since(start), + }, nil + } + createdIDs = append(createdIDs, wt.ID) + worktrees = append(worktrees, RepoWorktreeResult{ + RepositoryID: spec.RepositoryID, + WorktreeID: wt.ID, + WorktreeBranch: wt.Branch, + WorktreePath: wt.Path, + MainRepoGitDir: filepath.Join(spec.RepositoryPath, ".git"), + }) + } + + // Workspace path = task root (parent of any repo subdir). All repos share + // the same TaskDirName, so any worktree's parent works. + workspacePath := "" + if len(worktrees) > 0 { + workspacePath = filepath.Dir(worktrees[0].WorktreePath) + } + + res := &EnvPrepareResult{ + Success: true, + Steps: steps, + WorkspacePath: workspacePath, + Duration: time.Since(start), + Worktrees: worktrees, + } + // Mirror first repo into legacy fields for downstream consumers that + // haven't been migrated yet. + if len(worktrees) > 0 { + res.WorktreeID = worktrees[0].WorktreeID + res.WorktreeBranch = worktrees[0].WorktreeBranch + res.MainRepoGitDir = worktrees[0].MainRepoGitDir + } + return res, nil +} + +// prepareOneRepo runs the validate → create-worktree → fetch-PR → setup-script +// sequence for a single repo within a multi-repo preparation. Returns the +// created worktree, updated steps, the next step index, and any error. +func (p *WorktreePreparer) prepareOneRepo( + ctx context.Context, + req *EnvPrepareRequest, + spec RepoPrepareSpec, + stepIdx, totalSteps int, + onProgress PrepareProgressCallback, + steps []PrepareStep, +) (*worktree.Worktree, []PrepareStep, int, error) { + repoLabel := spec.RepoName + if repoLabel == "" { + repoLabel = spec.RepositoryID + } + + // Validate + validateStep := beginStep(fmt.Sprintf("Validate %s repository", repoLabel)) + reportProgress(onProgress, validateStep, stepIdx, totalSteps) + if spec.RepositoryPath == "" { + completeStepError(&validateStep, "no repository path provided") + steps = append(steps, validateStep) + reportProgress(onProgress, validateStep, stepIdx, totalSteps) + return nil, steps, stepIdx + 1, fmt.Errorf("repo %q: no repository path", repoLabel) + } + completeStepSuccess(&validateStep) + steps = append(steps, validateStep) + reportProgress(onProgress, validateStep, stepIdx, totalSteps) + stepIdx++ + + // Sync (optional) + Create — reuses single-repo helper by translating spec→req. + subReq := *req + subReq.RepositoryID = spec.RepositoryID + subReq.RepositoryPath = spec.RepositoryPath + subReq.RepoName = spec.RepoName + subReq.BaseBranch = spec.BaseBranch + subReq.CheckoutBranch = spec.CheckoutBranch + subReq.WorktreeID = spec.WorktreeID + subReq.WorktreeBranchPrefix = spec.WorktreeBranchPrefix + subReq.PullBeforeWorktree = spec.PullBeforeWorktree + // Strip the multi-repo list to avoid re-entering the multi-repo branch. + subReq.Repositories = nil + + wt, steps, stepIdx, err := p.createWorktreeWithSync(ctx, &subReq, stepIdx, totalSteps, onProgress, steps) + if err != nil { + return nil, steps, stepIdx, err + } + + // PR fetch step (mirrors single-repo path). + if spec.CheckoutBranch != "" { + step := beginStep(fmt.Sprintf("Fetch PR branch (%s)", repoLabel)) + step.Command = fmt.Sprintf("git fetch origin %s", spec.CheckoutBranch) + reportProgress(onProgress, step, stepIdx, totalSteps) + if wt.FetchWarning != "" { + step.Warning = wt.FetchWarning + step.WarningDetail = wt.FetchWarningDetail + } + completeStepSuccess(&step) + steps = append(steps, step) + reportProgress(onProgress, step, stepIdx, totalSteps) + stepIdx++ + } + + // Per-repo setup script (passed verbatim — no script-engine resolution + // in this phase; that lands with the per-repo placeholder namespace later). + if script := strings.TrimSpace(spec.RepoSetupScript); script != "" { + var runErr error + steps, runErr = runRepoSetupScriptStep(ctx, &subReq, repoLabel, wt.Path, script, stepIdx, totalSteps, onProgress, steps) + if runErr != nil { + return nil, steps, stepIdx + 1, fmt.Errorf("repo %q setup script failed: %w", repoLabel, runErr) + } + stepIdx++ + } + + return wt, steps, stepIdx, nil +} + +// runRepoSetupScriptStep executes one per-repo setup script and appends the +// step (with streaming output) to steps. Mirrors runSetupScriptStep but uses a +// repo-scoped step name and returns any execution error to the caller for +// rollback control. +func runRepoSetupScriptStep( + ctx context.Context, + req *EnvPrepareRequest, + repoLabel, workspacePath, script string, + stepIdx, totalSteps int, + onProgress PrepareProgressCallback, + steps []PrepareStep, +) ([]PrepareStep, error) { + step := beginStep(fmt.Sprintf("Run setup script (%s)", repoLabel)) + step.Command = setupScriptDisplayCommand(req) + reportProgress(onProgress, step, stepIdx, totalSteps) + output, err := runSetupScript(ctx, script, workspacePath, req.Env, func(current string) { + step.Output = current + reportProgress(onProgress, step, stepIdx, totalSteps) + }) + step.Output = output + if err != nil { + completeStepError(&step, err.Error()) + } else { + completeStepSuccess(&step) + } + steps = append(steps, step) + reportProgress(onProgress, step, stepIdx, totalSteps) + return steps, err +} + +// rollbackWorktrees removes any worktrees created during a failed multi-repo +// preparation. Best effort: failures are logged and do not interrupt teardown. +func (p *WorktreePreparer) rollbackWorktrees(ctx context.Context, ids []string) { + for _, id := range ids { + if err := p.worktreeMgr.RemoveByID(ctx, id, false); err != nil { + p.logger.Warn("rollback: failed to remove worktree", + zap.String("worktree_id", id), zap.Error(err)) + } + } +} + func applySyncProgressEvent(step *PrepareStep, event worktree.SyncProgressEvent) { if event.StepName != "" { step.Name = event.StepName diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_worktree_multi_test.go b/apps/backend/internal/agent/lifecycle/env_preparer_worktree_multi_test.go new file mode 100644 index 0000000000..9ecc2e16df --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/env_preparer_worktree_multi_test.go @@ -0,0 +1,327 @@ +package lifecycle + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/worktree" +) + +// inMemoryWorktreeStore is a minimal worktree.Store for preparer tests. +// Mirrors the package-internal mockStore, but lives here so tests don't need +// to import worktree-internal symbols. +type inMemoryWorktreeStore struct { + mu sync.Mutex + worktrees map[string]*worktree.Worktree +} + +func newInMemoryWorktreeStore() *inMemoryWorktreeStore { + return &inMemoryWorktreeStore{worktrees: make(map[string]*worktree.Worktree)} +} + +func (s *inMemoryWorktreeStore) CreateWorktree(_ context.Context, wt *worktree.Worktree) error { + s.mu.Lock() + defer s.mu.Unlock() + s.worktrees[wt.ID] = wt + return nil +} + +func (s *inMemoryWorktreeStore) GetWorktreeByID(_ context.Context, id string) (*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + wt, ok := s.worktrees[id] + if !ok { + return nil, nil + } + return wt, nil +} + +func (s *inMemoryWorktreeStore) GetWorktreeBySessionID(_ context.Context, sessionID string) (*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, wt := range s.worktrees { + if wt.SessionID == sessionID { + return wt, nil + } + } + return nil, nil +} + +func (s *inMemoryWorktreeStore) GetWorktreesByTaskID(_ context.Context, taskID string) ([]*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*worktree.Worktree + for _, wt := range s.worktrees { + if wt.TaskID == taskID { + out = append(out, wt) + } + } + return out, nil +} + +func (s *inMemoryWorktreeStore) GetWorktreesByRepositoryID(_ context.Context, repoID string) ([]*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*worktree.Worktree + for _, wt := range s.worktrees { + if wt.RepositoryID == repoID { + out = append(out, wt) + } + } + return out, nil +} + +func (s *inMemoryWorktreeStore) UpdateWorktree(_ context.Context, wt *worktree.Worktree) error { + s.mu.Lock() + defer s.mu.Unlock() + s.worktrees[wt.ID] = wt + return nil +} + +func (s *inMemoryWorktreeStore) DeleteWorktree(_ context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.worktrees, id) + return nil +} + +func (s *inMemoryWorktreeStore) ListActiveWorktrees(_ context.Context) ([]*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*worktree.Worktree + for _, wt := range s.worktrees { + if wt.Status == worktree.StatusActive { + out = append(out, wt) + } + } + return out, nil +} + +// GetWorktreesBySessionID — MultiRepoStore. +func (s *inMemoryWorktreeStore) GetWorktreesBySessionID(_ context.Context, sessionID string) ([]*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*worktree.Worktree + for _, wt := range s.worktrees { + if wt.SessionID == sessionID && wt.Status == worktree.StatusActive { + out = append(out, wt) + } + } + return out, nil +} + +// GetWorktreeBySessionAndRepository — MultiRepoStore. +func (s *inMemoryWorktreeStore) GetWorktreeBySessionAndRepository(_ context.Context, sessionID, repoID string) (*worktree.Worktree, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, wt := range s.worktrees { + if wt.SessionID == sessionID && wt.RepositoryID == repoID && wt.Status == worktree.StatusActive { + return wt, nil + } + } + return nil, nil +} + +// initBareGitRepo creates a single-commit git repo in a temp directory and +// returns its path. Used as a stand-in for a "real" repository in WorktreePreparer +// multi-repo tests. +func initBareGitRepo(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + mustGit := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s in %s failed: %v\n%s", strings.Join(args, " "), dir, err, out) + } + } + mustGit("init", "-b", "main") + mustGit("config", "user.email", "test@example.com") + mustGit("config", "user.name", "Test User") + mustGit("config", "commit.gpgsign", "false") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello "+name), 0644); err != nil { + t.Fatalf("write README: %v", err) + } + mustGit("add", "README.md") + mustGit("commit", "-m", "initial commit") + return dir +} + +func newPreparerForTest(t *testing.T) (*WorktreePreparer, *worktree.Manager) { + t.Helper() + tmp := t.TempDir() + cfg := worktree.Config{ + Enabled: true, + TasksBasePath: filepath.Join(tmp, "tasks"), + BranchPrefix: "feat/", + } + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + store := newInMemoryWorktreeStore() + mgr, err := worktree.NewManager(cfg, store, log) + if err != nil { + t.Fatalf("worktree manager: %v", err) + } + return NewWorktreePreparer(mgr, log), mgr +} + +func TestWorktreePreparer_MultiRepo_CreatesWorktreePerRepo(t *testing.T) { + repoA := initBareGitRepo(t, "frontend") + repoB := initBareGitRepo(t, "backend") + + preparer, _ := newPreparerForTest(t) + + req := &EnvPrepareRequest{ + TaskID: "task-multi-1", + SessionID: "sess-multi-1", + TaskTitle: "Multi Repo Task", + ExecutorType: executor.Name("worktree"), + TaskDirName: "multi-repo-task_aaa", + Repositories: []RepoPrepareSpec{ + {RepositoryID: "repo-front", RepositoryPath: repoA, RepoName: "frontend", BaseBranch: "main"}, + {RepositoryID: "repo-back", RepositoryPath: repoB, RepoName: "backend", BaseBranch: "main"}, + }, + } + + res, err := preparer.Prepare(context.Background(), req, nil) + if err != nil { + t.Fatalf("prepare: %v", err) + } + if !res.Success { + t.Fatalf("expected success; steps: %+v err: %s", res.Steps, res.ErrorMessage) + } + if len(res.Worktrees) != 2 { + t.Fatalf("expected 2 worktrees, got %d", len(res.Worktrees)) + } + if res.Worktrees[0].RepositoryID != "repo-front" || res.Worktrees[1].RepositoryID != "repo-back" { + t.Errorf("unexpected order: %+v", res.Worktrees) + } + // Legacy single-worktree fields mirror Worktrees[0]. + if res.WorktreeID != res.Worktrees[0].WorktreeID { + t.Errorf("legacy WorktreeID should mirror Worktrees[0]; got %q vs %q", res.WorktreeID, res.Worktrees[0].WorktreeID) + } + // WorkspacePath = parent of any repo subdir (the task root). + if res.WorkspacePath == "" { + t.Error("expected workspace_path to be set") + } + // Both worktrees should live under the same task root. + if filepath.Dir(res.Worktrees[0].WorktreePath) != filepath.Dir(res.Worktrees[1].WorktreePath) { + t.Errorf("worktrees should share task root: %s vs %s", + res.Worktrees[0].WorktreePath, res.Worktrees[1].WorktreePath) + } + // Each worktree directory must exist on disk. + for _, w := range res.Worktrees { + if _, err := os.Stat(w.WorktreePath); err != nil { + t.Errorf("worktree dir missing for %s: %v", w.RepositoryID, err) + } + } +} + +func TestWorktreePreparer_MultiRepo_RollbackOnPartialFailure(t *testing.T) { + repoA := initBareGitRepo(t, "good") + // repoB is intentionally a non-git directory to force the second create to fail. + repoB := t.TempDir() + + preparer, mgr := newPreparerForTest(t) + + req := &EnvPrepareRequest{ + TaskID: "task-multi-fail", + SessionID: "sess-multi-fail", + TaskTitle: "Failing Task", + ExecutorType: executor.Name("worktree"), + TaskDirName: "failing-task_bbb", + Repositories: []RepoPrepareSpec{ + {RepositoryID: "repo-good", RepositoryPath: repoA, RepoName: "good", BaseBranch: "main"}, + {RepositoryID: "repo-bad", RepositoryPath: repoB, RepoName: "bad", BaseBranch: "main"}, + }, + } + + res, err := preparer.Prepare(context.Background(), req, nil) + if err != nil { + t.Fatalf("prepare returned error (should report via result): %v", err) + } + if res.Success { + t.Fatal("expected failure when second repo is not a git repo") + } + if res.ErrorMessage == "" { + t.Error("expected non-empty error message") + } + + // Rollback: no worktrees should remain in the store after partial failure. + all, err := mgr.GetAllByTaskID(context.Background(), "task-multi-fail") + if err != nil { + t.Fatalf("list worktrees: %v", err) + } + for _, wt := range all { + if wt.Status == worktree.StatusActive { + t.Errorf("expected no active worktrees after rollback; found %s in %s", wt.ID, wt.Path) + } + } +} + +func TestWorktreePreparer_MultiRepo_RunsPerRepoSetupScript(t *testing.T) { + repoA := initBareGitRepo(t, "frontend") + repoB := initBareGitRepo(t, "backend") + + preparer, _ := newPreparerForTest(t) + + req := &EnvPrepareRequest{ + TaskID: "task-multi-setup", + SessionID: "sess-multi-setup", + TaskTitle: "Setup Task", + ExecutorType: executor.Name("worktree"), + TaskDirName: "setup-task_ccc", + Repositories: []RepoPrepareSpec{ + { + RepositoryID: "repo-front", + RepositoryPath: repoA, + RepoName: "frontend", + BaseBranch: "main", + RepoSetupScript: "echo front-script-ran > setup-marker.txt", + }, + { + RepositoryID: "repo-back", + RepositoryPath: repoB, + RepoName: "backend", + BaseBranch: "main", + RepoSetupScript: "echo back-script-ran > setup-marker.txt", + }, + }, + } + + res, err := preparer.Prepare(context.Background(), req, nil) + if err != nil { + t.Fatalf("prepare: %v", err) + } + if !res.Success { + t.Fatalf("expected success; err: %s", res.ErrorMessage) + } + if len(res.Worktrees) != 2 { + t.Fatalf("expected 2 worktrees; got %d", len(res.Worktrees)) + } + for i, w := range res.Worktrees { + marker := filepath.Join(w.WorktreePath, "setup-marker.txt") + if _, err := os.Stat(marker); err != nil { + t.Errorf("repo %d setup marker missing: %v", i, err) + } + } + // Each repo should produce a "Run setup script ()" step. + var setupStepsSeen int + for _, s := range res.Steps { + if strings.HasPrefix(s.Name, "Run setup script") { + setupStepsSeen++ + } + } + if setupStepsSeen != 2 { + t.Errorf("expected 2 per-repo setup script steps, got %d", setupStepsSeen) + } +} diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 5ce4c00c26..7a6336cb46 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -225,6 +225,10 @@ type GitStatusData struct { Files interface{} `json:"files,omitempty"` BranchAdditions int `json:"branch_additions,omitempty"` BranchDeletions int `json:"branch_deletions,omitempty"` + // RepositoryName identifies which repository this status belongs to in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so the Changes panel can render per-repo group headers. + RepositoryName string `json:"repository_name,omitempty"` } type GitCommitData struct { @@ -239,12 +243,20 @@ type GitCommitData struct { Deletions int `json:"deletions"` CommittedAt string `json:"committed_at"` CreatedAt string `json:"created_at,omitempty"` + // RepositoryName identifies which repo this commit belongs to in multi-repo + // task workspaces. Empty for single-repo. Carried to the frontend so the + // Commits panel can render per-repo group headers. + RepositoryName string `json:"repository_name,omitempty"` } type GitResetData struct { PreviousHead string `json:"previous_head"` CurrentHead string `json:"current_head"` DeletedCount int `json:"deleted_count"` + // RepositoryName identifies which repo this reset belongs to in multi-repo + // task workspaces. Empty for single-repo. Carried to the frontend so the + // Changes panel can scope its per-repo group state. + RepositoryName string `json:"repository_name,omitempty"` } type GitBranchSwitchData struct { @@ -252,6 +264,10 @@ type GitBranchSwitchData struct { CurrentBranch string `json:"current_branch"` CurrentHead string `json:"current_head"` BaseCommit string `json:"base_commit"` + // RepositoryName identifies which repo this branch switch belongs to in + // multi-repo task workspaces. Empty for single-repo. Carried to the + // frontend so the Changes panel can update the matching per-repo group. + RepositoryName string `json:"repository_name,omitempty"` } type GitSnapshotData struct { @@ -277,6 +293,10 @@ type FileChangeEventPayload struct { Path string `json:"path"` Operation string `json:"operation"` Timestamp string `json:"timestamp"` + // RepositoryName identifies which repo emitted this file change in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so per-repo views can scope refresh signals correctly. + RepositoryName string `json:"repository_name,omitempty"` } // GetSessionID returns the session ID for this event (used by event routing). diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 60b54997d6..ed66f0ea78 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -247,6 +247,7 @@ func (p *EventPublisher) PublishGitStatus(execution *AgentExecution, update *age Files: update.Files, BranchAdditions: update.BranchAdditions, BranchDeletions: update.BranchDeletions, + RepositoryName: update.RepositoryName, }, }) } @@ -260,15 +261,16 @@ func (p *EventPublisher) PublishGitCommit(execution *AgentExecution, commit *age AgentID: execution.ID, Timestamp: time.Now().Format(time.RFC3339Nano), Commit: &GitCommitData{ - CommitSHA: commit.CommitSHA, - ParentSHA: commit.ParentSHA, - Message: commit.Message, - AuthorName: commit.AuthorName, - AuthorEmail: commit.AuthorEmail, - FilesChanged: commit.FilesChanged, - Insertions: commit.Insertions, - Deletions: commit.Deletions, - CommittedAt: commit.CommittedAt.Format(time.RFC3339), + CommitSHA: commit.CommitSHA, + ParentSHA: commit.ParentSHA, + Message: commit.Message, + AuthorName: commit.AuthorName, + AuthorEmail: commit.AuthorEmail, + FilesChanged: commit.FilesChanged, + Insertions: commit.Insertions, + Deletions: commit.Deletions, + CommittedAt: commit.CommittedAt.Format(time.RFC3339), + RepositoryName: commit.RepositoryName, }, }) } @@ -282,8 +284,9 @@ func (p *EventPublisher) PublishGitReset(execution *AgentExecution, reset *agent AgentID: execution.ID, Timestamp: reset.Timestamp.Format(time.RFC3339Nano), Reset: &GitResetData{ - PreviousHead: reset.PreviousHead, - CurrentHead: reset.CurrentHead, + PreviousHead: reset.PreviousHead, + CurrentHead: reset.CurrentHead, + RepositoryName: reset.RepositoryName, }, }) } @@ -301,6 +304,7 @@ func (p *EventPublisher) PublishBranchSwitch(execution *AgentExecution, branchSw CurrentBranch: branchSwitch.CurrentBranch, CurrentHead: branchSwitch.CurrentHead, BaseCommit: branchSwitch.BaseCommit, + RepositoryName: branchSwitch.RepositoryName, }, }) } @@ -314,12 +318,13 @@ func (p *EventPublisher) PublishFileChange(execution *AgentExecution, notificati sessionID := execution.SessionID payload := FileChangeEventPayload{ - TaskID: execution.TaskID, - SessionID: sessionID, - AgentID: execution.ID, - Path: notification.Path, - Operation: notification.Operation, - Timestamp: notification.Timestamp.Format(time.RFC3339Nano), + TaskID: execution.TaskID, + SessionID: sessionID, + AgentID: execution.ID, + Path: notification.Path, + Operation: notification.Operation, + Timestamp: notification.Timestamp.Format(time.RFC3339Nano), + RepositoryName: notification.RepositoryName, } event := bus.NewEvent(events.FileChangeNotified, "agent-manager", payload) diff --git a/apps/backend/internal/agent/lifecycle/events_git_status_test.go b/apps/backend/internal/agent/lifecycle/events_git_status_test.go new file mode 100644 index 0000000000..7a7bef440e --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/events_git_status_test.go @@ -0,0 +1,62 @@ +package lifecycle + +import ( + "context" + "testing" + "time" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// Verifies the fix for "Changes panel shows no header / no existing changes". +// agentctl tags every per-repo GitStatusUpdate with RepositoryName; that field +// must survive the lifecycle PublishGitStatus translation so the orchestrator +// (and thus the frontend) sees it. +func TestPublishGitStatus_PropagatesRepositoryName(t *testing.T) { + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + eventBus := bus.NewMemoryEventBus(log) + pub := NewEventPublisher(eventBus, log) + + received := make(chan *bus.Event, 1) + subj := events.BuildGitEventSubject("sess-multi") + sub, err := eventBus.Subscribe(subj, func(_ context.Context, ev *bus.Event) error { + received <- ev + return nil + }) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer func() { _ = sub.Unsubscribe() }() + + exec := &AgentExecution{ + ID: "exec-1", + TaskID: "task-1", + SessionID: "sess-multi", + } + pub.PublishGitStatus(exec, &agentctl.GitStatusUpdate{ + Timestamp: time.Now(), + RepositoryName: "frontend", + Branch: "feature/x", + Modified: []string{"src/app.tsx"}, + Files: map[string]agentctl.FileInfo{"src/app.tsx": {Path: "src/app.tsx"}}, + }) + + select { + case ev := <-received: + payload, ok := ev.Data.(*GitEventPayload) + if !ok || payload == nil { + t.Fatalf("expected *GitEventPayload, got %T", ev.Data) + } + if payload.Status == nil { + t.Fatal("expected non-nil Status on payload") + } + if payload.Status.RepositoryName != "frontend" { + t.Errorf("repository_name was dropped: got %q", payload.Status.RepositoryName) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for git status event") + } +} diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go b/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go index 1d38a45655..c7dd082ff4 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go @@ -128,7 +128,7 @@ func TestSpritesE2E_FullFlow(t *testing.T) { require.NotEmpty(t, results.Files) t.Logf("found %d files matching 'gin.go'", len(results.Files)) - content, err := client.RequestFileContent(ctx, "go.mod") + content, err := client.RequestFileContent(ctx, "go.mod", "") require.NoError(t, err) require.Contains(t, content.Content, "github.com/gin-gonic/gin") t.Logf("go.mod: %d bytes", len(content.Content)) @@ -320,11 +320,11 @@ func TestSpritesE2E_FullFlow(t *testing.T) { _, err = client.CreateFile(ctx, "e2e-change.txt") require.NoError(t, err) - result, err := client.GitStage(ctx, []string{"e2e-change.txt"}) + result, err := client.GitStage(ctx, []string{"e2e-change.txt"}, "") require.NoError(t, err) require.True(t, result.Success, "git stage should succeed: %s", result.Error) - result, err = client.GitCommit(ctx, "e2e test commit", false) + result, err = client.GitCommit(ctx, "e2e test commit", false, false, "") require.NoError(t, err) require.True(t, result.Success, "git commit should succeed: %s", result.Error) t.Logf("git commit: %s", result.Output) diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 4613a2c525..3ed76dff24 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -391,6 +391,30 @@ func (m *Manager) runEnvironmentPreparer( RepoName: req.RepoName, Env: req.Env, } + // Multi-repo: forward the repo list when the launch request carries one. + // Each per-repo entry inherits the request-level RepoSetupScript when its + // own is empty so single-repo callers continue to work unchanged. + if len(req.Repositories) > 0 { + specs := make([]RepoPrepareSpec, 0, len(req.Repositories)) + for _, r := range req.Repositories { + setup := r.RepoSetupScript + if setup == "" { + setup = repoSetupScript + } + specs = append(specs, RepoPrepareSpec{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: setup, + }) + } + prepReq.Repositories = specs + } result, err := preparer.Prepare(ctx, prepReq, m.newProgressCallback(req.TaskID, req.SessionID)) if err != nil { diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index c29fd0c02f..84ad80bf9b 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -250,6 +250,24 @@ func (ae *AgentExecution) EndSessionSpan() { } } +// RepoLaunchSpec describes one repository for a multi-repo task launch. +// Mirrors the per-repo launch fields that LaunchRequest historically carried at +// the top level. When LaunchRequest.Repositories is set, each entry produces +// one prepared worktree under the shared TaskDirName. +type RepoLaunchSpec struct { + RepositoryID string + RepositoryPath string + RepositoryURL string // Clone URL for remote executors that need to clone + RepoName string // Repository name used as subdirectory inside TaskDirName + BaseBranch string + CheckoutBranch string + WorktreeID string // Existing worktree ID to reuse (skip creation if set) + WorktreeBranchPrefix string + PullBeforeWorktree bool + RepoSetupScript string // Repository-level setup script (optional) + RepoCleanupScript string // Repository-level cleanup script (optional) +} + // LaunchRequest contains parameters for launching an agent type LaunchRequest struct { TaskID string @@ -291,6 +309,35 @@ type LaunchRequest struct { // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") RepoName string // Repository name used as subdirectory inside the task directory + + // Repositories carries one entry per repository when the launch is multi-repo. + // When non-empty it is the source of truth; the legacy single-repo top-level + // fields above are populated from Repositories[0] for callers that have not + // yet been updated. + Repositories []RepoLaunchSpec +} + +// RepoSpecs returns the per-repo launch specs for this request. When +// Repositories is set it is returned verbatim; otherwise a length-1 list is +// synthesized from the legacy top-level single-repo fields. Returns an empty +// slice for repo-less launches (e.g. quick chat). +func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec { + if len(r.Repositories) > 0 { + return r.Repositories + } + if r.RepositoryID == "" && r.RepositoryPath == "" { + return nil + } + return []RepoLaunchSpec{{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + }} } // MessageAttachment represents an image or file attachment for agent prompts. diff --git a/apps/backend/internal/agent/lifecycle/types_repospecs_test.go b/apps/backend/internal/agent/lifecycle/types_repospecs_test.go new file mode 100644 index 0000000000..b9cb9d1edb --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/types_repospecs_test.go @@ -0,0 +1,92 @@ +package lifecycle + +import "testing" + +func TestLaunchRequest_RepoSpecs_ReturnsExplicitListWhenSet(t *testing.T) { + req := &LaunchRequest{ + // Single-repo top-level fields are populated from Repositories[0] for + // backwards compat by callers, but RepoSpecs() must always prefer the + // explicit list when present. + RepositoryID: "legacy-repo", + RepositoryPath: "/legacy", + Repositories: []RepoLaunchSpec{ + {RepositoryID: "repo-a", RepositoryPath: "/a", BaseBranch: "main"}, + {RepositoryID: "repo-b", RepositoryPath: "/b", BaseBranch: "develop"}, + }, + } + specs := req.RepoSpecs() + if len(specs) != 2 { + t.Fatalf("expected 2 specs, got %d", len(specs)) + } + if specs[0].RepositoryID != "repo-a" || specs[1].RepositoryID != "repo-b" { + t.Errorf("unexpected order: %+v", specs) + } +} + +func TestLaunchRequest_RepoSpecs_SynthesizesFromLegacyFields(t *testing.T) { + req := &LaunchRequest{ + RepositoryID: "repo-x", + RepositoryPath: "/x", + BaseBranch: "main", + CheckoutBranch: "feature/y", + WorktreeID: "wt-1", + WorktreeBranchPrefix: "feat/", + PullBeforeWorktree: true, + RepoName: "x", + } + specs := req.RepoSpecs() + if len(specs) != 1 { + t.Fatalf("expected length-1 list synthesized, got %d", len(specs)) + } + got := specs[0] + if got.RepositoryID != "repo-x" || got.RepositoryPath != "/x" || + got.BaseBranch != "main" || got.CheckoutBranch != "feature/y" || + got.WorktreeID != "wt-1" || got.WorktreeBranchPrefix != "feat/" || + !got.PullBeforeWorktree || got.RepoName != "x" { + t.Errorf("synthesized spec mismatch: %+v", got) + } +} + +func TestLaunchRequest_RepoSpecs_NilForRepoLessLaunch(t *testing.T) { + req := &LaunchRequest{TaskID: "t1", SessionID: "s1"} + if specs := req.RepoSpecs(); specs != nil { + t.Errorf("expected nil for repo-less launch, got %v", specs) + } +} + +func TestEnvPrepareRequest_RepoSpecs_ExplicitWins(t *testing.T) { + req := &EnvPrepareRequest{ + RepositoryID: "legacy", + RepositoryPath: "/legacy", + Repositories: []RepoPrepareSpec{ + {RepositoryID: "a", RepositoryPath: "/a"}, + }, + } + specs := req.RepoSpecs() + if len(specs) != 1 || specs[0].RepositoryID != "a" { + t.Fatalf("expected explicit list to win; got %+v", specs) + } +} + +func TestEnvPrepareRequest_RepoSpecs_SynthesizedCarriesRepoSetupScript(t *testing.T) { + req := &EnvPrepareRequest{ + RepositoryID: "r1", + RepositoryPath: "/r1", + BaseBranch: "main", + RepoSetupScript: "make install", + } + specs := req.RepoSpecs() + if len(specs) != 1 { + t.Fatalf("expected 1 spec, got %d", len(specs)) + } + if specs[0].RepoSetupScript != "make install" { + t.Errorf("repo setup script not propagated: %+v", specs[0]) + } +} + +func TestEnvPrepareRequest_RepoSpecs_NilForRepoLess(t *testing.T) { + req := &EnvPrepareRequest{} + if specs := req.RepoSpecs(); specs != nil { + t.Errorf("expected nil, got %+v", specs) + } +} diff --git a/apps/backend/internal/agentctl/client/client_files.go b/apps/backend/internal/agentctl/client/client_files.go index 9efa5daa52..5846f881db 100644 --- a/apps/backend/internal/agentctl/client/client_files.go +++ b/apps/backend/internal/agentctl/client/client_files.go @@ -79,9 +79,13 @@ func (c *Client) SearchFiles(ctx context.Context, query string, limit int) (*Fil return &result, nil } -// RequestFileContent requests file content via HTTP GET -func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileContentResponse, error) { +// RequestFileContent requests file content via HTTP GET. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) RequestFileContent(ctx context.Context, path, repo string) (*FileContentResponse, error) { reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content?path=%s", c.baseURL, url.QueryEscape(path)) + if repo != "" { + reqURL += "&repo=" + url.QueryEscape(repo) + } req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { @@ -110,9 +114,13 @@ func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileCont return &response, nil } -// RequestFileContentAtRef requests file content at a specific git ref via HTTP GET -func (c *Client) RequestFileContentAtRef(ctx context.Context, path string, ref string) (*FileContentResponse, error) { +// RequestFileContentAtRef requests file content at a specific git ref via HTTP GET. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) RequestFileContentAtRef(ctx context.Context, path, ref, repo string) (*FileContentResponse, error) { reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content-at-ref?path=%s&ref=%s", c.baseURL, url.QueryEscape(path), url.QueryEscape(ref)) + if repo != "" { + reqURL += "&repo=" + url.QueryEscape(repo) + } req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { @@ -141,10 +149,12 @@ func (c *Client) RequestFileContentAtRef(ctx context.Context, path string, ref s return &response, nil } -// ApplyFileDiff applies a unified diff to a file via HTTP POST -func (c *Client) ApplyFileDiff(ctx context.Context, path, diff, originalHash string, desiredContent *string) (*streams.FileUpdateResponse, error) { +// ApplyFileDiff applies a unified diff to a file via HTTP POST. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) ApplyFileDiff(ctx context.Context, path, diff, originalHash, repo string, desiredContent *string) (*streams.FileUpdateResponse, error) { reqBody := streams.FileUpdateRequest{ Path: path, + Repo: repo, Diff: diff, OriginalHash: originalHash, DesiredContent: desiredContent, @@ -188,9 +198,10 @@ func (c *Client) ApplyFileDiff(ctx context.Context, path, diff, originalHash str return &response, nil } -// CreateFile creates a new file via HTTP POST -func (c *Client) CreateFile(ctx context.Context, path string) (*streams.FileCreateResponse, error) { - reqBody := streams.FileCreateRequest{Path: path} +// CreateFile creates a new file via HTTP POST. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) CreateFile(ctx context.Context, path, repo string) (*streams.FileCreateResponse, error) { + reqBody := streams.FileCreateRequest{Path: path, Repo: repo} bodyBytes, err := json.Marshal(reqBody) if err != nil { @@ -230,9 +241,13 @@ func (c *Client) CreateFile(ctx context.Context, path string) (*streams.FileCrea return &response, nil } -// DeleteFile deletes a file via HTTP DELETE -func (c *Client) DeleteFile(ctx context.Context, path string) (*streams.FileDeleteResponse, error) { +// DeleteFile deletes a file via HTTP DELETE. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) DeleteFile(ctx context.Context, path, repo string) (*streams.FileDeleteResponse, error) { reqURL := fmt.Sprintf("%s/api/v1/workspace/file?path=%s", c.baseURL, url.QueryEscape(path)) + if repo != "" { + reqURL += "&repo=" + url.QueryEscape(repo) + } req, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, nil) if err != nil { @@ -265,9 +280,10 @@ func (c *Client) DeleteFile(ctx context.Context, path string) (*streams.FileDele return &response, nil } -// RenameFile renames/moves a file or directory via HTTP POST -func (c *Client) RenameFile(ctx context.Context, oldPath, newPath string) (*streams.FileRenameResponse, error) { - reqBody := streams.FileRenameRequest{OldPath: oldPath, NewPath: newPath} +// RenameFile renames/moves a file or directory via HTTP POST. +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) RenameFile(ctx context.Context, oldPath, newPath, repo string) (*streams.FileRenameResponse, error) { + reqBody := streams.FileRenameRequest{OldPath: oldPath, NewPath: newPath, Repo: repo} bodyBytes, err := json.Marshal(reqBody) if err != nil { diff --git a/apps/backend/internal/agentctl/client/git.go b/apps/backend/internal/agentctl/client/git.go index 0cf7cf6cbf..e2cb5ea557 100644 --- a/apps/backend/internal/agentctl/client/git.go +++ b/apps/backend/internal/agentctl/client/git.go @@ -30,11 +30,14 @@ type PRCreateResult struct { // GitPull performs a git pull operation on the worktree. // If rebase is true, uses git pull --rebase. -func (c *Client) GitPull(ctx context.Context, rebase bool) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitPull(ctx context.Context, rebase bool, repo string) (*GitOperationResult, error) { payload := struct { - Rebase bool `json:"rebase"` + Rebase bool `json:"rebase"` + Repo string `json:"repo,omitempty"` }{ Rebase: rebase, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/pull", payload) } @@ -42,46 +45,58 @@ func (c *Client) GitPull(ctx context.Context, rebase bool) (*GitOperationResult, // GitPush performs a git push operation on the worktree. // If force is true, uses --force-with-lease. // If setUpstream is true, uses --set-upstream. -func (c *Client) GitPush(ctx context.Context, force, setUpstream bool) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitPush(ctx context.Context, force, setUpstream bool, repo string) (*GitOperationResult, error) { payload := struct { - Force bool `json:"force"` - SetUpstream bool `json:"set_upstream"` + Force bool `json:"force"` + SetUpstream bool `json:"set_upstream"` + Repo string `json:"repo,omitempty"` }{ Force: force, SetUpstream: setUpstream, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/push", payload) } // GitRebase rebases the worktree branch onto the specified base branch. // It first fetches origin/, then rebases onto it. -func (c *Client) GitRebase(ctx context.Context, baseBranch string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitRebase(ctx context.Context, baseBranch, repo string) (*GitOperationResult, error) { payload := struct { BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` }{ BaseBranch: baseBranch, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/rebase", payload) } // GitMerge merges the specified base branch into the worktree branch. // It first fetches origin/, then merges it. -func (c *Client) GitMerge(ctx context.Context, baseBranch string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitMerge(ctx context.Context, baseBranch, repo string) (*GitOperationResult, error) { payload := struct { BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` }{ BaseBranch: baseBranch, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/merge", payload) } // GitAbort aborts an in-progress merge or rebase operation. // The operation parameter must be "merge" or "rebase". -func (c *Client) GitAbort(ctx context.Context, operation string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitAbort(ctx context.Context, operation, repo string) (*GitOperationResult, error) { payload := struct { Operation string `json:"operation"` + Repo string `json:"repo,omitempty"` }{ Operation: operation, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/abort", payload) } @@ -89,97 +104,121 @@ func (c *Client) GitAbort(ctx context.Context, operation string) (*GitOperationR // GitCommit creates a commit with the specified message. // If stageAll is true, all changes are staged before committing. // If amend is true, it amends the previous commit instead of creating a new one. -func (c *Client) GitCommit(ctx context.Context, message string, stageAll bool, amend bool) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitCommit(ctx context.Context, message string, stageAll bool, amend bool, repo string) (*GitOperationResult, error) { payload := struct { Message string `json:"message"` StageAll bool `json:"stage_all"` Amend bool `json:"amend"` + Repo string `json:"repo,omitempty"` }{ Message: message, StageAll: stageAll, Amend: amend, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/commit", payload) } // GitRenameBranch renames the current branch to a new name. -func (c *Client) GitRenameBranch(ctx context.Context, newName string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitRenameBranch(ctx context.Context, newName, repo string) (*GitOperationResult, error) { payload := struct { NewName string `json:"new_name"` + Repo string `json:"repo,omitempty"` }{ NewName: newName, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/rename-branch", payload) } // GitStage stages files for commit. // If paths is empty, stages all changes (git add -A). -func (c *Client) GitStage(ctx context.Context, paths []string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitStage(ctx context.Context, paths []string, repo string) (*GitOperationResult, error) { payload := struct { Paths []string `json:"paths"` + Repo string `json:"repo,omitempty"` }{ Paths: paths, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/stage", payload) } // GitUnstage unstages files from the index. // If paths is empty, unstages all changes (git reset HEAD). -func (c *Client) GitUnstage(ctx context.Context, paths []string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitUnstage(ctx context.Context, paths []string, repo string) (*GitOperationResult, error) { payload := struct { Paths []string `json:"paths"` + Repo string `json:"repo,omitempty"` }{ Paths: paths, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/unstage", payload) } // GitDiscard discards changes to files, reverting them to HEAD. // Paths must not be empty - at least one file must be specified. -func (c *Client) GitDiscard(ctx context.Context, paths []string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitDiscard(ctx context.Context, paths []string, repo string) (*GitOperationResult, error) { payload := struct { Paths []string `json:"paths"` + Repo string `json:"repo,omitempty"` }{ Paths: paths, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/discard", payload) } // GitRevertCommit undoes the latest commit using git reset --soft, keeping changes staged. -func (c *Client) GitRevertCommit(ctx context.Context, commitSHA string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitRevertCommit(ctx context.Context, commitSHA, repo string) (*GitOperationResult, error) { payload := struct { CommitSHA string `json:"commit_sha"` + Repo string `json:"repo,omitempty"` }{ CommitSHA: commitSHA, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/revert-commit", payload) } // GitReset resets HEAD to the specified commit. // Mode can be "soft" (keep changes staged), "mixed" (keep changes unstaged), or "hard" (discard all changes). -func (c *Client) GitReset(ctx context.Context, commitSHA, mode string) (*GitOperationResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitReset(ctx context.Context, commitSHA, mode, repo string) (*GitOperationResult, error) { payload := struct { CommitSHA string `json:"commit_sha"` Mode string `json:"mode"` + Repo string `json:"repo,omitempty"` }{ CommitSHA: commitSHA, Mode: mode, + Repo: repo, } return c.gitOperation(ctx, "/api/v1/git/reset", payload) } // GitCreatePR creates a pull request using the gh CLI. -func (c *Client) GitCreatePR(ctx context.Context, title, body, baseBranch string, draft bool) (*PRCreateResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitCreatePR(ctx context.Context, title, body, baseBranch string, draft bool, repo string) (*PRCreateResult, error) { payload := struct { Title string `json:"title"` Body string `json:"body"` BaseBranch string `json:"base_branch"` Draft bool `json:"draft"` + Repo string `json:"repo,omitempty"` }{ Title: title, Body: body, BaseBranch: baseBranch, Draft: draft, + Repo: repo, } reqBody, err := json.Marshal(payload) @@ -273,8 +312,15 @@ type CommitDiffResult struct { } // GitShowCommit gets the diff for a specific commit. -func (c *Client) GitShowCommit(ctx context.Context, commitSHA string) (*CommitDiffResult, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/git/commit/"+commitSHA, nil) +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +// Multi-repo tasks must pass the owning repo because a SHA from one repo's +// commit graph isn't resolvable in any other repo. +func (c *Client) GitShowCommit(ctx context.Context, commitSHA, repo string) (*CommitDiffResult, error) { + reqURL := c.baseURL + "/api/v1/git/commit/" + commitSHA + if repo != "" { + reqURL += "?repo=" + url.QueryEscape(repo) + } + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -308,6 +354,18 @@ type GitLogResult struct { Success bool `json:"success"` Commits []*GitCommitInfo `json:"commits"` Error string `json:"error,omitempty"` + // PerRepoErrors lists per-repo failures during a multi-repo log fan-out. + // Empty/nil for single-repo responses or when every repo succeeded. Mirrors + // the server's process.GitLogResult.PerRepoErrors field. + PerRepoErrors []GitLogRepoError `json:"per_repo_errors,omitempty"` +} + +// GitLogRepoError describes a single per-repo failure from a multi-repo log +// fan-out. Mirrors the server's process.GitLogRepoError type so callers using +// the agentctl client can deserialize and surface partial failures. +type GitLogRepoError struct { + RepositoryName string `json:"repository_name"` + Error string `json:"error"` } // GitCommitInfo represents a single commit in the log. @@ -322,17 +380,25 @@ type GitCommitInfo struct { FilesChanged int `json:"files_changed"` Insertions int `json:"insertions"` Deletions int `json:"deletions"` + // RepositoryName tags commits when fetched from a multi-repo subpath. Empty + // for single-repo workspaces. Stamped client-side after the per-repo call + // so callers can fan out across repos and merge. + RepositoryName string `json:"repository_name,omitempty"` } // GitLog gets the commit log from baseCommit to HEAD. // If baseCommit is empty, returns recent commits (limited by limit). // If targetBranch is provided, computes merge-base dynamically for accurate filtering. -func (c *Client) GitLog(ctx context.Context, baseCommit string, limit int, targetBranch string) (*GitLogResult, error) { +// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. +func (c *Client) GitLog(ctx context.Context, baseCommit string, limit int, targetBranch, repo string) (*GitLogResult, error) { reqURL := fmt.Sprintf("%s/api/v1/git/log?since=%s&limit=%d", c.baseURL, url.QueryEscape(baseCommit), limit) if targetBranch != "" { reqURL += "&target_branch=" + url.QueryEscape(targetBranch) } + if repo != "" { + reqURL += "&repo=" + url.QueryEscape(repo) + } req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { @@ -427,33 +493,73 @@ type GitStatusResult struct { Error string `json:"error,omitempty"` } -// GetGitStatus gets the current git status from the workspace. -func (c *Client) GetGitStatus(ctx context.Context) (*GitStatusResult, error) { - req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/git/status", nil) +// fetchJSONResult performs a GET against `path` and decodes the response into +// `out`. Returns the HTTP status code so callers can decide whether to wrap +// any structured-error body into the typed result. Centralises the boilerplate +// shared between the status endpoints (which is what triggers `dupl`). +func (c *Client) fetchJSONResult(ctx context.Context, path string, out any) (int, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return 0, fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) + return 0, fmt.Errorf("failed to execute request: %w", err) } defer func() { _ = resp.Body.Close() }() - respBody, err := readResponseBody(resp) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return resp.StatusCode, fmt.Errorf("failed to read response body: %w", err) } - - var result GitStatusResult - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w", + if err := json.Unmarshal(respBody, out); err != nil { + return resp.StatusCode, fmt.Errorf("failed to parse response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) } + return resp.StatusCode, nil +} - if resp.StatusCode >= 400 { - return &result, fmt.Errorf("git status failed with status %d: %s", resp.StatusCode, result.Error) +// GetGitStatus gets the current git status from the workspace. +func (c *Client) GetGitStatus(ctx context.Context) (*GitStatusResult, error) { + var result GitStatusResult + status, err := c.fetchJSONResult(ctx, "/api/v1/git/status", &result) + if err != nil { + return nil, err + } + if status >= 400 { + return &result, fmt.Errorf("git status failed with status %d: %s", status, result.Error) + } + return &result, nil +} + +// PerRepoGitStatus pairs a repository_name with its status. Mirrors the +// server-side shape so the gateway can fan a single subscribe out into one +// notification per repo (multi-repo workspaces) without re-running git +// commands once per call. +type PerRepoGitStatus struct { + RepositoryName string `json:"repository_name"` + Status GitStatusResult `json:"status"` +} + +// MultiRepoGitStatusResult is the response shape for /api/v1/git/status/multi. +type MultiRepoGitStatusResult struct { + Success bool `json:"success"` + Repos []PerRepoGitStatus `json:"repos"` + Error string `json:"error,omitempty"` +} + +// GetGitStatusMulti returns one status entry per repo (multi-repo) or a +// single untagged entry (single-repo). Used by the session-subscribe handler +// in the main backend to seed the frontend's per-repo state on page reload — +// without it, the frontend never sees per-repo grouping until something else +// (a file change, a poll) pushes a stamped notification. +func (c *Client) GetGitStatusMulti(ctx context.Context) (*MultiRepoGitStatusResult, error) { + var result MultiRepoGitStatusResult + status, err := c.fetchJSONResult(ctx, "/api/v1/git/status/multi", &result) + if err != nil { + return nil, err + } + if status >= 400 { + return &result, fmt.Errorf("git status multi failed with status %d: %s", status, result.Error) } - return &result, nil } diff --git a/apps/backend/internal/agentctl/server/api/git.go b/apps/backend/internal/agentctl/server/api/git.go index b422746780..888962e94b 100644 --- a/apps/backend/internal/agentctl/server/api/git.go +++ b/apps/backend/internal/agentctl/server/api/git.go @@ -4,36 +4,63 @@ import ( "errors" "fmt" "net/http" + "sort" + "time" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/agentctl/server/process" "go.uber.org/zap" ) +// sortCommitsByCommittedAtDesc sorts commits newest-first using committed_at. +// Commits with unparseable timestamps preserve their original relative order +// (sort.SliceStable). Used when merging logs from multiple repos so the +// combined timeline reads chronologically. +func sortCommitsByCommittedAtDesc(commits []*process.GitCommitInfo) { + sort.SliceStable(commits, func(i, j int) bool { + ti, errI := time.Parse(time.RFC3339, commits[i].CommittedAt) + tj, errJ := time.Parse(time.RFC3339, commits[j].CommittedAt) + if errI != nil || errJ != nil { + return false + } + return ti.After(tj) + }) +} + +// Repo selects a repository sub-directory of the workspace for multi-repo +// task roots. Optional. Empty = workspace root (single-repo behavior). +// All git request structs below embed this field via the standard `repo` JSON +// key (or `repo` form key for GET endpoints). + // GitPullRequest for POST /api/v1/git/pull type GitPullRequest struct { - Rebase bool `json:"rebase"` + Rebase bool `json:"rebase"` + Repo string `json:"repo,omitempty"` } // GitPushRequest for POST /api/v1/git/push type GitPushRequest struct { - Force bool `json:"force"` - SetUpstream bool `json:"set_upstream"` + Force bool `json:"force"` + SetUpstream bool `json:"set_upstream"` + Repo string `json:"repo,omitempty"` } // GitRebaseRequest for POST /api/v1/git/rebase type GitRebaseRequest struct { BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` } // GitMergeRequest for POST /api/v1/git/merge type GitMergeRequest struct { BaseBranch string `json:"base_branch"` + Repo string `json:"repo,omitempty"` } // GitAbortRequest for POST /api/v1/git/abort type GitAbortRequest struct { Operation string `json:"operation"` // "merge" or "rebase" + Repo string `json:"repo,omitempty"` } // GitCommitRequest for POST /api/v1/git/commit @@ -41,26 +68,31 @@ type GitCommitRequest struct { Message string `json:"message"` StageAll bool `json:"stage_all"` Amend bool `json:"amend"` + Repo string `json:"repo,omitempty"` } // GitRenameBranchRequest for POST /api/v1/git/rename-branch type GitRenameBranchRequest struct { NewName string `json:"new_name"` + Repo string `json:"repo,omitempty"` } // GitStageRequest for POST /api/v1/git/stage type GitStageRequest struct { Paths []string `json:"paths"` // Empty = stage all + Repo string `json:"repo,omitempty"` } // GitUnstageRequest for POST /api/v1/git/unstage type GitUnstageRequest struct { Paths []string `json:"paths"` // Empty = unstage all + Repo string `json:"repo,omitempty"` } // GitDiscardRequest for POST /api/v1/git/discard type GitDiscardRequest struct { Paths []string `json:"paths"` // Required - files to discard + Repo string `json:"repo,omitempty"` } // GitShowCommitRequest for GET /api/v1/git/commit/:sha @@ -71,6 +103,7 @@ type GitShowCommitRequest struct { // GitRevertCommitRequest for POST /api/v1/git/revert-commit type GitRevertCommitRequest struct { CommitSHA string `json:"commit_sha"` + Repo string `json:"repo,omitempty"` } // GitCreatePRRequest for POST /api/v1/git/create-pr @@ -79,12 +112,14 @@ type GitCreatePRRequest struct { Body string `json:"body"` BaseBranch string `json:"base_branch"` Draft bool `json:"draft"` + Repo string `json:"repo,omitempty"` } // GitResetRequest for POST /api/v1/git/reset type GitResetRequest struct { CommitSHA string `json:"commit_sha"` Mode string `json:"mode"` // "soft", "mixed", or "hard" + Repo string `json:"repo,omitempty"` } // GitLogRequest for GET /api/v1/git/log @@ -92,11 +127,29 @@ type GitLogRequest struct { Since string `form:"since"` // Base commit SHA (exclusive) TargetBranch string `form:"target_branch"` // Target branch for merge-base calculation (e.g., "origin/main") Limit int `form:"limit"` // Max commits to return + Repo string `form:"repo"` } // GitCumulativeDiffRequest for GET /api/v1/git/cumulative-diff type GitCumulativeDiffRequest struct { Base string `form:"base" binding:"required"` // Base commit SHA + Repo string `form:"repo"` +} + +// gitOpForRepo resolves the optional repo subpath to a per-repo GitOperator. +// On invalid subpath it writes a 400 response and returns nil so callers can +// `return` immediately. Empty subpath returns the workspace-root operator. +func (s *Server) gitOpForRepo(c *gin.Context, operation, subpath string) *process.GitOperator { + op, err := s.procMgr.GitOperatorFor(subpath) + if err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: operation, + Error: err.Error(), + }) + return nil + } + return op } // handleGitPull handles POST /api/v1/git/pull @@ -111,7 +164,10 @@ func (s *Server) handleGitPull(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "pull", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Pull(c.Request.Context(), req.Rebase) if err != nil { s.handleGitError(c, "pull", err) @@ -133,7 +189,10 @@ func (s *Server) handleGitPush(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "push", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Push(c.Request.Context(), req.Force, req.SetUpstream) if err != nil { s.handleGitError(c, "push", err) @@ -164,7 +223,10 @@ func (s *Server) handleGitRebase(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "rebase", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Rebase(c.Request.Context(), req.BaseBranch) if err != nil { s.handleGitError(c, "rebase", err) @@ -195,7 +257,10 @@ func (s *Server) handleGitMerge(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "merge", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Merge(c.Request.Context(), req.BaseBranch) if err != nil { s.handleGitError(c, "merge", err) @@ -226,7 +291,10 @@ func (s *Server) handleGitAbort(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "abort", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Abort(c.Request.Context(), req.Operation) if err != nil { s.handleGitError(c, "abort", err) @@ -257,7 +325,10 @@ func (s *Server) handleGitCommit(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "commit", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Commit(c.Request.Context(), req.Message, req.StageAll, req.Amend) if err != nil { s.handleGitError(c, "commit", err) @@ -288,7 +359,10 @@ func (s *Server) handleGitRenameBranch(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "rename_branch", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.RenameBranch(c.Request.Context(), req.NewName) if err != nil { s.handleGitError(c, "rename_branch", err) @@ -310,7 +384,10 @@ func (s *Server) handleGitStage(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "stage", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Stage(c.Request.Context(), req.Paths) if err != nil { s.handleGitError(c, "stage", err) @@ -332,7 +409,10 @@ func (s *Server) handleGitUnstage(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "unstage", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Unstage(c.Request.Context(), req.Paths) if err != nil { s.handleGitError(c, "unstage", err) @@ -363,7 +443,10 @@ func (s *Server) handleGitDiscard(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "discard", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Discard(c.Request.Context(), req.Paths) if err != nil { s.handleGitError(c, "discard", err) @@ -392,7 +475,14 @@ func (s *Server) handleGitCreatePR(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp, gitOpErr := s.procMgr.GitOperatorFor(req.Repo) + if gitOpErr != nil { + c.JSON(http.StatusBadRequest, process.PRCreateResult{ + Success: false, + Error: gitOpErr.Error(), + }) + return + } result, err := gitOp.CreatePR(c.Request.Context(), req.Title, req.Body, req.BaseBranch, req.Draft) if err != nil { if errors.Is(err, process.ErrOperationInProgress) { @@ -434,7 +524,10 @@ func (s *Server) handleGitRevertCommit(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "revert_commit", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.RevertCommit(c.Request.Context(), req.CommitSHA) if err != nil { s.handleGitError(c, "revert_commit", err) @@ -455,7 +548,16 @@ func (s *Server) handleGitShowCommit(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + // ShowCommit accepts an optional ?repo= query param. + gitOp, gitOpErr := s.procMgr.GitOperatorFor(c.Query("repo")) + if gitOpErr != nil { + c.JSON(http.StatusBadRequest, process.CommitDiffResult{ + Success: false, + CommitSHA: req.CommitSHA, + Error: gitOpErr.Error(), + }) + return + } result, err := gitOp.ShowCommit(c.Request.Context(), req.CommitSHA) if err != nil { s.logger.Error("git show commit failed", zap.String("commit_sha", req.CommitSHA), zap.Error(err)) @@ -504,7 +606,10 @@ func (s *Server) handleGitReset(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + gitOp := s.gitOpForRepo(c, "reset", req.Repo) + if gitOp == nil { + return + } result, err := gitOp.Reset(c.Request.Context(), req.CommitSHA, req.Mode) if err != nil { s.handleGitError(c, "reset", err) @@ -514,7 +619,13 @@ func (s *Server) handleGitReset(c *gin.Context) { c.JSON(http.StatusOK, result) } -// handleGitLog handles GET /api/v1/git/log +// handleGitLog handles GET /api/v1/git/log. +// +// Multi-repo: when the caller doesn't pin a repo (?repo= empty) and the +// workspace contains per-repo subdirs, fan out one log per repo and merge +// the results, stamping RepositoryName on each commit so the frontend can +// group them. Without this, the call would land on the workspace root which +// for multi-repo task workspaces isn't a git repo and returns nothing. func (s *Server) handleGitLog(c *gin.Context) { var req GitLogRequest if err := c.ShouldBindQuery(&req); err != nil { @@ -541,38 +652,182 @@ func (s *Server) handleGitLog(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() + if req.Repo == "" { + if subs := s.procMgr.RepoSubpaths(); len(subs) > 0 { + s.handleGitLogMultiRepo(c, req, subs, limit) + return + } + } + + result, err := s.runGitLogForRepo(c, req, limit, req.Repo) + if err != nil { + s.handleGitError(c, "log", err) + return + } + c.JSON(http.StatusOK, result) +} + +// runGitLogForRepo runs git log against a single repo subpath. Returns a +// result-with-error or a non-nil error for transport failures. +func (s *Server) runGitLogForRepo( + c *gin.Context, + req GitLogRequest, + limit int, + repo string, +) (*process.GitLogResult, error) { + gitOp, gitOpErr := s.procMgr.GitOperatorFor(repo) + if gitOpErr != nil { + return nil, gitOpErr + } - // If target_branch is provided, compute merge-base dynamically. - // This ensures we only show commits not yet merged into the target branch, - // which is accurate even after rebases. baseCommit := req.Since if req.TargetBranch != "" { mergeBase, err := gitOp.GetMergeBase(c.Request.Context(), "HEAD", req.TargetBranch) if err != nil { s.logger.Warn("failed to compute merge-base, falling back to since", zap.String("target_branch", req.TargetBranch), + zap.String("repo", repo), zap.Error(err)) - // Fall back to the provided base commit } else if mergeBase != "" { baseCommit = mergeBase } } - result, err := gitOp.GetLog(c.Request.Context(), baseCommit, limit) - if err != nil { - s.logger.Error("git log failed", zap.Error(err)) - c.JSON(http.StatusInternalServerError, process.GitLogResult{ - Success: false, - Error: err.Error(), - }) - return - } - - c.JSON(http.StatusOK, result) + return gitOp.GetLog(c.Request.Context(), baseCommit, limit) } -// handleGitCumulativeDiff handles GET /api/v1/git/cumulative-diff +// perRepoLogOutcome captures the outcome of running git log against a single +// repo subpath. Exactly one of result/err is set; result.Success may still be +// false if the per-repo command itself reported a failure. +type perRepoLogOutcome struct { + subpath string + result *process.GitLogResult + err error +} + +// mergeGitLogResults combines per-repo log outcomes into a single response +// suitable for the multi-repo log endpoint. It is extracted from +// handleGitLogMultiRepo so the merge/error-aggregation logic is unit-testable +// without spinning up a real Manager. +// +// Behavior: +// - Successful repos contribute their commits (each tagged with RepositoryName). +// - Failed repos (transport error or result.Success=false) contribute an +// entry to PerRepoErrors so the frontend can render an "incomplete" warning. +// - Success=true iff at least one repo succeeded. If every repo failed, +// Success=false with a summary in Error. +// - Commits are sorted newest-first across repos and truncated to limit. +func mergeGitLogResults(outcomes []perRepoLogOutcome, limit int) process.GitLogResult { + merged := make([]*process.GitCommitInfo, 0) + perRepoErrors := make([]process.GitLogRepoError, 0) + for _, o := range outcomes { + if o.err != nil { + perRepoErrors = append(perRepoErrors, process.GitLogRepoError{ + RepositoryName: o.subpath, + Error: o.err.Error(), + }) + continue + } + if o.result == nil || !o.result.Success { + errMsg := "" + if o.result != nil { + errMsg = o.result.Error + } + perRepoErrors = append(perRepoErrors, process.GitLogRepoError{ + RepositoryName: o.subpath, + Error: errMsg, + }) + continue + } + for _, commit := range o.result.Commits { + commit.RepositoryName = o.subpath + merged = append(merged, commit) + } + } + + // Sort newest-first across repos. Falls back to insertion order if either + // timestamp is unparseable. + sortCommitsByCommittedAtDesc(merged) + if limit > 0 && len(merged) > limit { + merged = merged[:limit] + } + + resp := process.GitLogResult{Commits: merged} + if len(perRepoErrors) > 0 { + resp.PerRepoErrors = perRepoErrors + } + // Success iff at least one repo succeeded. When every repo failed we mark + // the response failed and put a one-line summary in Error so callers that + // only check Success/Error keep working without inspecting PerRepoErrors. + switch { + case len(outcomes) > 0 && len(perRepoErrors) == len(outcomes): + resp.Success = false + resp.Error = fmt.Sprintf("git log failed for all %d repositories", len(outcomes)) + default: + resp.Success = true + } + return resp +} + +// handleGitLogMultiRepo fans the request out across every per-repo tracker, +// stamps RepositoryName on each returned commit, and merges into a single +// log sorted by committed_at descending. Per-repo limits are applied +// individually; the merged result is then truncated to the request limit. +// +// Per-repo failures are surfaced via PerRepoErrors rather than silently +// dropped, so the frontend can render an "incomplete" warning instead of +// pretending the missing repo simply has no history. The merged response +// stays Success=true as long as at least one repo succeeded; if every repo +// failed, Success=false with a summary in Error. +func (s *Server) handleGitLogMultiRepo( + c *gin.Context, + req GitLogRequest, + subpaths []string, + limit int, +) { + outcomes := make([]perRepoLogOutcome, 0, len(subpaths)) + for _, sub := range subpaths { + // Multi-repo: the caller-supplied `since` is a SHA that only exists in + // the primary repo, and `target_branch` is the primary repo's base + // branch (e.g. "main"). Both can be wrong for sibling repos — running + // `git log ..HEAD` in lvc fails outright and the repo's + // commits silently disappear from the merged response. Compute a + // per-repo base via merge-base against `origin/main` (with + // `origin/master` fallback) so each repo's commits show up. + perRepoReq := req + perRepoReq.Since = "" + perRepoReq.TargetBranch = "" + if base := s.resolvePerRepoBase(c, sub); base != "" { + perRepoReq.Since = base + } + result, err := s.runGitLogForRepo(c, perRepoReq, limit, sub) + if err != nil { + s.logger.Warn("git log for repo failed", + zap.String("repo", sub), zap.Error(err)) + } else if !result.Success { + s.logger.Warn("git log for repo returned failure", + zap.String("repo", sub), zap.String("error", result.Error)) + } + outcomes = append(outcomes, perRepoLogOutcome{ + subpath: sub, + result: result, + err: err, + }) + } + + c.JSON(http.StatusOK, mergeGitLogResults(outcomes, limit)) +} + +// handleGitCumulativeDiff handles GET /api/v1/git/cumulative-diff. +// +// Multi-repo: when the caller doesn't pin a repo (?repo= empty) and the +// workspace contains per-repo subdirs, fan out one cumulative diff per repo +// and merge results, prefixing each file key with the repo subpath and +// stamping repository_name onto each file's payload. Without this the call +// would land on the workspace root which for multi-repo task workspaces +// isn't a git repo — and `git diff` would silently ascend to whatever .git +// encloses the task root (e.g. the user's outer kandev checkout) and return +// hundreds of unrelated files. func (s *Server) handleGitCumulativeDiff(c *gin.Context) { var req GitCumulativeDiffRequest if err := c.ShouldBindQuery(&req); err != nil { @@ -583,18 +838,142 @@ func (s *Server) handleGitCumulativeDiff(c *gin.Context) { return } - gitOp := s.procMgr.GitOperator() - result, err := gitOp.GetCumulativeDiff(c.Request.Context(), req.Base) + if req.Repo == "" { + if subs := s.procMgr.RepoSubpaths(); len(subs) > 0 { + s.handleGitCumulativeDiffMultiRepo(c, req, subs) + return + } + } + + result := s.runGitCumulativeDiffForRepo(c, req.Base, req.Repo) + if result == nil { + return // gitOp lookup error already wrote the response + } + c.JSON(http.StatusOK, result) +} + +// runGitCumulativeDiffForRepo runs cumulative diff against a single repo +// subpath. Returns nil after writing an error response on lookup failures; +// otherwise returns the (possibly success=false) result for the caller. +func (s *Server) runGitCumulativeDiffForRepo( + c *gin.Context, + base, repo string, +) *process.CumulativeDiffResult { + gitOp, gitOpErr := s.procMgr.GitOperatorFor(repo) + if gitOpErr != nil { + c.JSON(http.StatusBadRequest, process.CumulativeDiffResult{ + Success: false, + Error: gitOpErr.Error(), + }) + return nil + } + result, err := gitOp.GetCumulativeDiff(c.Request.Context(), base) if err != nil { - s.logger.Error("git cumulative diff failed", zap.String("base", req.Base), zap.Error(err)) + s.logger.Error("git cumulative diff failed", + zap.String("base", base), + zap.String("repo", repo), + zap.Error(err)) c.JSON(http.StatusInternalServerError, process.CumulativeDiffResult{ Success: false, Error: err.Error(), }) - return + return nil } + return result +} - c.JSON(http.StatusOK, result) +// handleGitCumulativeDiffMultiRepo fans cumulative diff out across every +// per-repo tracker. Each repo's base commit is computed locally via +// merge-base against the integration branch — the caller-supplied base only +// makes sense for one repo at a time, since each repo has its own commit +// graph. Files from each repo are merged into a single map, keyed by +// `/` so paths that exist in multiple repos don't clash, +// and tagged with `repository_name` so the frontend can group them. +func (s *Server) handleGitCumulativeDiffMultiRepo( + c *gin.Context, + req GitCumulativeDiffRequest, + subpaths []string, +) { + merged := process.CumulativeDiffResult{ + Files: make(map[string]interface{}), + Success: true, + } + anyOK := false + for _, sub := range subpaths { + base := s.resolvePerRepoBase(c, sub) + if base == "" { + s.logger.Warn("cumulative diff: no per-repo base, skipping", + zap.String("repo", sub)) + continue + } + result := s.runGitCumulativeDiffForRepo(c, base, sub) + if result == nil { + return // response already written + } + if !result.Success { + s.logger.Warn("cumulative diff for repo returned failure", + zap.String("repo", sub), + zap.String("error", result.Error)) + continue + } + anyOK = true + merged.TotalCommits += result.TotalCommits + mergeCumulativeFiles(merged.Files, result.Files, sub) + } + if !anyOK { + merged.Success = false + merged.Error = fmt.Sprintf("cumulative diff failed for all %d repositories", len(subpaths)) + } + c.JSON(http.StatusOK, merged) +} + +// resolvePerRepoBase returns the merge-base of HEAD against the integration +// branch (origin/main, with origin/master fallback). Multi-repo tasks share +// no single base commit across repos, so each repo computes its own. +func (s *Server) resolvePerRepoBase(c *gin.Context, repo string) string { + gitOp, err := s.procMgr.GitOperatorFor(repo) + if err != nil { + return "" + } + for _, candidate := range []string{"origin/main", "origin/master"} { + base, mbErr := gitOp.GetMergeBase(c.Request.Context(), "HEAD", candidate) + if mbErr == nil && base != "" { + return base + } + } + return "" +} + +// mergeCumulativeFiles copies per-repo files into the merged map under a +// ` ` key (NUL-separated) and decorates each file payload +// with `repository_name` + a `path` field carrying the repo-relative path. +// The composite key keeps `README.md` in two repos from clashing in the map; +// the frontend reads `path` and `repository_name` off the payload so the +// file tree groups under the repo header without the prefix bleeding into +// the displayed path. NUL is impossible in real paths, so the key is +// always uniquely splittable and the displayed path is unaffected. +func mergeCumulativeFiles(dst, src map[string]interface{}, repo string) { + for path, payload := range src { + m, ok := payload.(map[string]interface{}) + if !ok { + // Defensive: only known shape from parseCommitDiff is map[string]interface{}. + // If a future change emits something else, route it through unchanged + // rather than silently dropping the path/repo metadata. + dst[fmt.Sprintf("%s\x00%s", repo, path)] = payload + continue + } + // Shallow-copy the per-file map before stamping repository_name + + // path so the caller's source map isn't mutated. Earlier code wrote + // directly to `m`, which permanently rewrote the per-repo result + // before it could be reused (e.g. emitted to a second consumer). + copied := make(map[string]interface{}, len(m)+2) + for k, v := range m { + copied[k] = v + } + copied["repository_name"] = repo + copied["path"] = path + dst[fmt.Sprintf("%s\x00%s", repo, path)] = copied + } } // GitStatusResult represents the result of a git status query. @@ -618,9 +997,111 @@ type GitStatusResult struct { Error string `json:"error,omitempty"` } -// handleGitStatus handles GET /api/v1/git/status +// PerRepoGitStatus pairs a repository_name with its current status. Used by +// the multi-repo status fan-out so callers (notably the gateway's session +// subscribe handler) can deliver one stamped GitStatusUpdate per repo on +// reconnect — without it the frontend would only see the workspace-root +// (untagged) status and miss the per-repo grouping. +type PerRepoGitStatus struct { + RepositoryName string `json:"repository_name"` + Status GitStatusResult `json:"status"` +} + +// MultiRepoGitStatusResult is the response shape for /api/v1/git/status/multi. +// For multi-repo task workspaces the response carries one entry per repo, +// stamped with its name. Single-repo workspaces return a single entry with an +// empty repository_name so callers can treat both shapes uniformly. +type MultiRepoGitStatusResult struct { + Success bool `json:"success"` + Repos []PerRepoGitStatus `json:"repos"` + Error string `json:"error,omitempty"` +} + +// handleGitStatusMulti returns one git status entry per repo for multi-repo +// task workspaces (or one untagged entry for single-repo). Used by the +// session-subscribe handler in the main backend to seed per-repo state on +// page reload — the legacy single GET /api/v1/git/status endpoint returns +// only the workspace-root status, which is empty for multi-repo task roots +// (the root isn't itself a git repo). +func (s *Server) handleGitStatusMulti(c *gin.Context) { + subpaths := s.procMgr.RepoSubpaths() + // Single-repo: fall back to the workspace-root status with an empty repo + // name so the response shape stays uniform. + if len(subpaths) == 0 { + subpaths = []string{""} + } + result := MultiRepoGitStatusResult{Success: true, Repos: make([]PerRepoGitStatus, 0, len(subpaths))} + for _, sub := range subpaths { + entry := s.collectStatusForRepo(c, sub) + result.Repos = append(result.Repos, entry) + } + c.JSON(http.StatusOK, result) +} + +// collectStatusForRepo runs the status query for a single subpath and packs +// it into a PerRepoGitStatus. Failures land in Status.Error / Status.Success +// so the caller can render partial results instead of erroring out the whole +// fan-out when one repo is misconfigured. +func (s *Server) collectStatusForRepo(c *gin.Context, sub string) PerRepoGitStatus { + wt, wtErr := s.procMgr.GetWorkspaceTrackerFor(sub) + if wtErr != nil { + return PerRepoGitStatus{ + RepositoryName: sub, + Status: GitStatusResult{Success: false, Error: wtErr.Error()}, + } + } + if wt == nil { + return PerRepoGitStatus{ + RepositoryName: sub, + Status: GitStatusResult{Success: false, Error: "workspace tracker not available"}, + } + } + status, err := wt.GetCurrentGitStatus(c.Request.Context()) + if err != nil { + return PerRepoGitStatus{ + RepositoryName: sub, + Status: GitStatusResult{Success: false, Error: err.Error()}, + } + } + filesMap := make(map[string]interface{}, len(status.Files)) + for k, v := range status.Files { + filesMap[k] = v + } + return PerRepoGitStatus{ + RepositoryName: sub, + Status: GitStatusResult{ + Success: true, + Branch: status.Branch, + RemoteBranch: status.RemoteBranch, + HeadCommit: status.HeadCommit, + BaseCommit: status.BaseCommit, + Ahead: status.Ahead, + Behind: status.Behind, + Modified: status.Modified, + Added: status.Added, + Deleted: status.Deleted, + Untracked: status.Untracked, + Renamed: status.Renamed, + Files: filesMap, + Timestamp: status.Timestamp.Format("2006-01-02T15:04:05.000Z07:00"), + BranchAdditions: status.BranchAdditions, + BranchDeletions: status.BranchDeletions, + }, + } +} + +// handleGitStatus handles GET /api/v1/git/status. Accepts an optional +// ?repo= query param that selects a sub-directory of the workspace +// for multi-repo task roots; empty = workspace root (single-repo behavior). func (s *Server) handleGitStatus(c *gin.Context) { - wt := s.procMgr.GetWorkspaceTracker() + wt, wtErr := s.procMgr.GetWorkspaceTrackerFor(c.Query("repo")) + if wtErr != nil { + c.JSON(http.StatusBadRequest, GitStatusResult{ + Success: false, + Error: wtErr.Error(), + }) + return + } if wt == nil { c.JSON(http.StatusInternalServerError, GitStatusResult{ Success: false, diff --git a/apps/backend/internal/agentctl/server/api/git_log_multi_repo_test.go b/apps/backend/internal/agentctl/server/api/git_log_multi_repo_test.go new file mode 100644 index 0000000000..9d3a819326 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/git_log_multi_repo_test.go @@ -0,0 +1,195 @@ +package api + +import ( + "errors" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/process" +) + +// sortCommitsByCommittedAtDesc must order commits newest-first so the merged +// per-repo log reads chronologically, regardless of which repo bucket each +// commit came from. Unparseable timestamps preserve their relative order via +// SliceStable so we don't randomly reshuffle bad data. +func TestSortCommitsByCommittedAtDesc(t *testing.T) { + t.Parallel() + + t.Run("interleaves commits from multiple repos by timestamp", func(t *testing.T) { + commits := []*process.GitCommitInfo{ + {CommitSHA: "a", CommittedAt: "2026-04-26T10:00:00Z", RepositoryName: "frontend"}, + {CommitSHA: "b", CommittedAt: "2026-04-26T12:00:00Z", RepositoryName: "backend"}, + {CommitSHA: "c", CommittedAt: "2026-04-26T11:00:00Z", RepositoryName: "frontend"}, + } + sortCommitsByCommittedAtDesc(commits) + got := []string{commits[0].CommitSHA, commits[1].CommitSHA, commits[2].CommitSHA} + want := []string{"b", "c", "a"} + for i := range got { + if got[i] != want[i] { + t.Errorf("position %d: got %q, want %q (full order: %v)", i, got[i], want[i], got) + } + } + }) + + t.Run("preserves order on unparseable timestamps", func(t *testing.T) { + commits := []*process.GitCommitInfo{ + {CommitSHA: "x", CommittedAt: "not-a-date"}, + {CommitSHA: "y", CommittedAt: "also-bad"}, + } + sortCommitsByCommittedAtDesc(commits) + if commits[0].CommitSHA != "x" || commits[1].CommitSHA != "y" { + t.Errorf("expected stable order on bad timestamps; got %s, %s", + commits[0].CommitSHA, commits[1].CommitSHA) + } + }) +} + +// TestMergeGitLogResults verifies that mergeGitLogResults aggregates per-repo +// outcomes into the right shape: commits stamped with RepositoryName, partial +// failures surfaced via PerRepoErrors, and Success/Error reflecting whether +// the whole fan-out failed. +func TestMergeGitLogResults(t *testing.T) { + t.Parallel() + + t.Run("partial failure: one repo succeeds, one fails", func(t *testing.T) { + outcomes := []perRepoLogOutcome{ + { + subpath: "frontend", + result: &process.GitLogResult{ + Success: true, + Commits: []*process.GitCommitInfo{ + {CommitSHA: "a", CommittedAt: "2026-04-26T10:00:00Z"}, + }, + }, + }, + { + subpath: "backend", + err: errors.New("repo not found"), + }, + } + got := mergeGitLogResults(outcomes, 100) + if !got.Success { + t.Errorf("expected Success=true when at least one repo succeeded; got false (Error=%q)", got.Error) + } + if got.Error != "" { + t.Errorf("expected empty Error on partial success; got %q", got.Error) + } + if len(got.PerRepoErrors) != 1 { + t.Fatalf("expected 1 per-repo error; got %d", len(got.PerRepoErrors)) + } + if got.PerRepoErrors[0].RepositoryName != "backend" { + t.Errorf("per-repo error subject: got %q, want %q", + got.PerRepoErrors[0].RepositoryName, "backend") + } + if got.PerRepoErrors[0].Error != "repo not found" { + t.Errorf("per-repo error text: got %q, want %q", + got.PerRepoErrors[0].Error, "repo not found") + } + if len(got.Commits) != 1 || got.Commits[0].CommitSHA != "a" { + t.Errorf("expected 1 commit from frontend; got %v", got.Commits) + } + if got.Commits[0].RepositoryName != "frontend" { + t.Errorf("commit RepositoryName not stamped: got %q", got.Commits[0].RepositoryName) + } + }) + + t.Run("partial failure: result.Success=false from per-repo", func(t *testing.T) { + outcomes := []perRepoLogOutcome{ + { + subpath: "frontend", + result: &process.GitLogResult{ + Success: true, + Commits: []*process.GitCommitInfo{{CommitSHA: "a", CommittedAt: "2026-04-26T10:00:00Z"}}, + }, + }, + { + subpath: "backend", + result: &process.GitLogResult{Success: false, Error: "git log: bad ref"}, + }, + } + got := mergeGitLogResults(outcomes, 100) + if !got.Success { + t.Errorf("expected Success=true with partial failure") + } + if len(got.PerRepoErrors) != 1 || got.PerRepoErrors[0].Error != "git log: bad ref" { + t.Errorf("expected PerRepoErrors carrying the inner error; got %+v", got.PerRepoErrors) + } + }) + + t.Run("all repos fail: Success=false with summary error", func(t *testing.T) { + outcomes := []perRepoLogOutcome{ + {subpath: "frontend", err: errors.New("transport boom")}, + {subpath: "backend", result: &process.GitLogResult{Success: false, Error: "bad ref"}}, + } + got := mergeGitLogResults(outcomes, 100) + if got.Success { + t.Errorf("expected Success=false when every repo failed") + } + if got.Error == "" { + t.Errorf("expected non-empty Error summary when every repo failed") + } + if len(got.PerRepoErrors) != 2 { + t.Fatalf("expected 2 per-repo errors; got %d", len(got.PerRepoErrors)) + } + // Order matches input order. + if got.PerRepoErrors[0].RepositoryName != "frontend" || + got.PerRepoErrors[1].RepositoryName != "backend" { + t.Errorf("per-repo error order: got %+v", got.PerRepoErrors) + } + if len(got.Commits) != 0 { + t.Errorf("expected no commits; got %d", len(got.Commits)) + } + }) + + t.Run("all repos succeed: Success=true with empty PerRepoErrors", func(t *testing.T) { + outcomes := []perRepoLogOutcome{ + { + subpath: "frontend", + result: &process.GitLogResult{ + Success: true, + Commits: []*process.GitCommitInfo{{CommitSHA: "a", CommittedAt: "2026-04-26T10:00:00Z"}}, + }, + }, + { + subpath: "backend", + result: &process.GitLogResult{ + Success: true, + Commits: []*process.GitCommitInfo{{CommitSHA: "b", CommittedAt: "2026-04-26T11:00:00Z"}}, + }, + }, + } + got := mergeGitLogResults(outcomes, 100) + if !got.Success { + t.Errorf("expected Success=true; Error=%q", got.Error) + } + if got.Error != "" { + t.Errorf("expected empty Error on full success; got %q", got.Error) + } + if len(got.PerRepoErrors) != 0 { + t.Errorf("expected empty PerRepoErrors; got %+v", got.PerRepoErrors) + } + if len(got.Commits) != 2 { + t.Fatalf("expected 2 commits merged; got %d", len(got.Commits)) + } + // Newest first ordering. + if got.Commits[0].CommitSHA != "b" || got.Commits[1].CommitSHA != "a" { + t.Errorf("expected newest-first order [b, a]; got [%s, %s]", + got.Commits[0].CommitSHA, got.Commits[1].CommitSHA) + } + // RepositoryName stamped. + if got.Commits[0].RepositoryName != "backend" || got.Commits[1].RepositoryName != "frontend" { + t.Errorf("RepositoryName not stamped correctly: %+v", got.Commits) + } + }) + + t.Run("empty outcomes: Success=true with no commits or errors", func(t *testing.T) { + // Defensive: callers shouldn't invoke us with zero subpaths, but if + // they do we should not classify "no work" as "everything failed". + got := mergeGitLogResults(nil, 100) + if !got.Success { + t.Errorf("expected Success=true on empty input; got false (Error=%q)", got.Error) + } + if len(got.Commits) != 0 || len(got.PerRepoErrors) != 0 { + t.Errorf("expected empty result on empty input; got %+v", got) + } + }) +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 7206e8a3c7..0264c6a346 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -155,6 +155,7 @@ func (s *Server) setupRoutes() { api.GET("/git/log", s.handleGitLog) api.GET("/git/cumulative-diff", s.handleGitCumulativeDiff) api.GET("/git/status", s.handleGitStatus) + api.GET("/git/status/multi", s.handleGitStatusMulti) } // Utility agent routes diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index 3cca6c3a08..1e23807a11 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -27,9 +27,12 @@ func (s *Server) handleWorkspaceStreamWS(c *gin.Context) { s.logger.Info("Workspace stream WebSocket connected") - // Subscribe to unified workspace updates - sub := s.procMgr.GetWorkspaceTracker().SubscribeWorkspaceStream() - defer s.procMgr.GetWorkspaceTracker().UnsubscribeWorkspaceStream(sub) + // Subscribe to unified workspace updates. Manager.SubscribeWorkspaceStream + // fans out across the root tracker plus every per-repo tracker, so for + // multi-repo task roots the client receives one event per repo (each + // tagged with RepositoryName) on a single channel. + sub := s.procMgr.SubscribeWorkspaceStream() + defer s.procMgr.UnsubscribeWorkspaceStream(sub) // Get shell for input handling (may be nil) shell := s.procMgr.Shell() @@ -108,7 +111,9 @@ func (s *Server) handleFileTree(c *gin.Context) { c.JSON(200, types.FileTreeResponse{Root: tree}) } -// handleFileContent handles file content requests via HTTP GET +// handleFileContent handles file content requests via HTTP GET. +// Optional `repo` query param scopes the read to a per-repo subdirectory for +// multi-repo task workspaces; `path` is treated as repo-relative when set. func (s *Server) handleFileContent(c *gin.Context) { path := c.Query("path") if path == "" { @@ -116,16 +121,26 @@ func (s *Server) handleFileContent(c *gin.Context) { return } - content, size, isBinary, resolvedPath, err := s.procMgr.GetWorkspaceTracker().GetFileContent(path) + scopedPath, err := s.procMgr.JoinRepoPath(c.Query("repo"), path) + if err != nil { + c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error()}) + return + } + + content, size, isBinary, resolvedPath, err := s.procMgr.GetWorkspaceTracker().GetFileContent(scopedPath) if err != nil { c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size}) return } + // Return the repo-relative `path` to the caller — the `repo` scoping is an + // internal detail; the frontend tracks repository_name separately. c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size, IsBinary: isBinary, ResolvedPath: resolvedPath}) } -// handleFileContentAtRef handles file content requests at a specific git ref via HTTP GET +// handleFileContentAtRef handles file content requests at a specific git ref via HTTP GET. +// Optional `repo` query param scopes the read to a per-repo subdirectory for +// multi-repo task workspaces. func (s *Server) handleFileContentAtRef(c *gin.Context) { path := c.Query("path") if path == "" { @@ -139,7 +154,13 @@ func (s *Server) handleFileContentAtRef(c *gin.Context) { return } - content, size, isBinary, err := s.procMgr.GetWorkspaceTracker().GetFileContentAtRef(c.Request.Context(), path, ref) + scopedPath, err := s.procMgr.JoinRepoPath(c.Query("repo"), path) + if err != nil { + c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error()}) + return + } + + content, size, isBinary, err := s.procMgr.GetWorkspaceTracker().GetFileContentAtRef(c.Request.Context(), scopedPath, ref) if err != nil { c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size}) return @@ -166,8 +187,18 @@ func (s *Server) handleFileUpdate(c *gin.Context) { return } + scopedPath, err := s.procMgr.JoinRepoPath(req.Repo, req.Path) + if err != nil { + c.JSON(400, streams.FileUpdateResponse{ + Path: req.Path, + Success: false, + Error: err.Error(), + }) + return + } + // Apply the diff - newHash, resolution, err := s.procMgr.GetWorkspaceTracker().ApplyFileDiff(req.Path, req.Diff, req.OriginalHash, req.DesiredContent) + newHash, resolution, err := s.procMgr.GetWorkspaceTracker().ApplyFileDiff(scopedPath, req.Diff, req.OriginalHash, req.DesiredContent) if err != nil { c.JSON(400, streams.FileUpdateResponse{ Path: req.Path, @@ -200,7 +231,11 @@ func (s *Server) handleFileSearch(c *gin.Context) { c.JSON(200, types.FileSearchResponse{Files: files}) } -// handleFileCreate handles file create requests via HTTP POST +// handleFileCreate handles file create requests via HTTP POST. +// Optional `Repo` body field scopes the create to a per-repo subdirectory +// for multi-repo task workspaces; without it a UI-driven create on a +// multi-repo task lands in the bare task root (one level above any actual +// repo) instead of inside the intended repo. func (s *Server) handleFileCreate(c *gin.Context) { var req streams.FileCreateRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -213,9 +248,15 @@ func (s *Server) handleFileCreate(c *gin.Context) { return } - s.logger.Info("handleFileCreate: creating file", zap.String("path", req.Path)) + scopedPath, joinErr := s.procMgr.JoinRepoPath(req.Repo, req.Path) + if joinErr != nil { + c.JSON(400, streams.FileCreateResponse{Path: req.Path, Error: joinErr.Error()}) + return + } - err := s.procMgr.GetWorkspaceTracker().CreateFile(req.Path) + s.logger.Info("handleFileCreate: creating file", zap.String("path", req.Path), zap.String("repo", req.Repo)) + + err := s.procMgr.GetWorkspaceTracker().CreateFile(scopedPath) if err != nil { c.JSON(400, streams.FileCreateResponse{ Path: req.Path, @@ -233,7 +274,9 @@ func (s *Server) handleFileCreate(c *gin.Context) { }) } -// handleFileDelete handles file delete requests via HTTP DELETE +// handleFileDelete handles file delete requests via HTTP DELETE. +// Optional `repo` query param scopes the delete to a per-repo subdirectory +// for multi-repo task workspaces. func (s *Server) handleFileDelete(c *gin.Context) { path := c.Query("path") if path == "" { @@ -241,7 +284,13 @@ func (s *Server) handleFileDelete(c *gin.Context) { return } - err := s.procMgr.GetWorkspaceTracker().DeleteFile(path) + scopedPath, joinErr := s.procMgr.JoinRepoPath(c.Query("repo"), path) + if joinErr != nil { + c.JSON(400, streams.FileDeleteResponse{Path: path, Error: joinErr.Error()}) + return + } + + err := s.procMgr.GetWorkspaceTracker().DeleteFile(scopedPath) if err != nil { c.JSON(400, streams.FileDeleteResponse{ Path: path, @@ -257,7 +306,10 @@ func (s *Server) handleFileDelete(c *gin.Context) { }) } -// handleFileRename handles file/directory rename requests via HTTP POST +// handleFileRename handles file/directory rename requests via HTTP POST. +// Optional `Repo` body field scopes BOTH old_path and new_path to a per-repo +// subdirectory; cross-repo renames aren't supported (the rename collapses +// to a same-repo move). func (s *Server) handleFileRename(c *gin.Context) { var req streams.FileRenameRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -274,7 +326,18 @@ func (s *Server) handleFileRename(c *gin.Context) { return } - err := s.procMgr.GetWorkspaceTracker().RenameFile(req.OldPath, req.NewPath) + scopedOld, oldErr := s.procMgr.JoinRepoPath(req.Repo, req.OldPath) + if oldErr != nil { + c.JSON(400, streams.FileRenameResponse{OldPath: req.OldPath, NewPath: req.NewPath, Error: oldErr.Error()}) + return + } + scopedNew, newErr := s.procMgr.JoinRepoPath(req.Repo, req.NewPath) + if newErr != nil { + c.JSON(400, streams.FileRenameResponse{OldPath: req.OldPath, NewPath: req.NewPath, Error: newErr.Error()}) + return + } + + err := s.procMgr.GetWorkspaceTracker().RenameFile(scopedOld, scopedNew) if err != nil { c.JSON(400, streams.FileRenameResponse{ OldPath: req.OldPath, diff --git a/apps/backend/internal/agentctl/server/api/workspace_state.go b/apps/backend/internal/agentctl/server/api/workspace_state.go index ef6e0537b6..0397f77dcf 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_state.go +++ b/apps/backend/internal/agentctl/server/api/workspace_state.go @@ -19,6 +19,19 @@ type SetPollModeRequest struct { // See plan: focus-gated git polling. The gateway computes a workspace-level // mode (fast if any session focused, slow if any subscribed, paused otherwise) // and pushes it here so the tracker can throttle expensive git scans. +// +// Multi-repo: the per-repo trackers also need the mode update — otherwise they +// keep their construction-time default (fast) and never throttle, AND they +// miss the focus-triggered immediate scan that handleMonitorModeChange does +// on a transition INTO fast. Fanning out keeps every repo in lockstep. +// +// Snapshot-on-focus: a transition to fast/slow forces a RefreshGitStatus on +// every tracker so a fresh git status reaches subscribers. monitorTick +// otherwise only pushes on detected change; without this, a page opened +// after the agent has been idle would see no events until the user makes +// a file change. Multi-repo amplifies it: per-repo state is a Map and the +// aggregator only renders the repos that have published — missing events +// = missing files in the Changes panel, not just stale data. func (s *Server) handleSetPollMode(c *gin.Context) { var req SetPollModeRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -32,7 +45,7 @@ func (s *Server) handleSetPollMode(c *gin.Context) { return } - s.procMgr.GetWorkspaceTracker().SetPollMode(mode) + s.procMgr.SetWorkspacePollMode(c.Request.Context(), mode) s.logger.Debug("workspace poll mode updated", zap.String("mode", req.Mode)) c.JSON(http.StatusOK, gin.H{"mode": req.Mode}) diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go index ab56f108f0..8241cc6073 100644 --- a/apps/backend/internal/agentctl/server/process/git.go +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -39,6 +39,10 @@ type GitOperator struct { workDir string logger *logger.Logger workspaceTracker *WorkspaceTracker + // repoName is the multi-repo subpath this operator runs in (e.g. "kandev"). + // Empty for the workspace-root operator. Stamped on emitted commit + // notifications so the frontend can group commits per repo. + repoName string mu sync.Mutex // Prevents concurrent git operations inProgress bool @@ -54,6 +58,14 @@ func NewGitOperator(workDir string, log *logger.Logger, workspaceTracker *Worksp } } +// NewGitOperatorForRepo creates a GitOperator scoped to a multi-repo subpath +// so its emitted events (e.g. commit notifications) carry the repo name. +func NewGitOperatorForRepo(workDir, repoName string, log *logger.Logger, workspaceTracker *WorkspaceTracker) *GitOperator { + op := NewGitOperator(workDir, log, workspaceTracker) + op.repoName = repoName + return op +} + // runGitCommand executes a git command in the workDir with defense-in-depth validation. // Validates both flags and branch/ref arguments to prevent command injection. func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string, error) { @@ -503,15 +515,16 @@ func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool, filesChanged, insertions, deletions := g.getCommitStats(ctx, strings.TrimSpace(commitSHA)) commit := &streams.GitCommitNotification{ - CommitSHA: strings.TrimSpace(commitSHA), - ParentSHA: strings.TrimSpace(parentSHA), - Message: message, - AuthorName: authorName, - AuthorEmail: authorEmail, - FilesChanged: filesChanged, - Insertions: insertions, - Deletions: deletions, - CommittedAt: time.Now().UTC(), + RepositoryName: g.repoName, + CommitSHA: strings.TrimSpace(commitSHA), + ParentSHA: strings.TrimSpace(parentSHA), + Message: message, + AuthorName: authorName, + AuthorEmail: authorEmail, + FilesChanged: filesChanged, + Insertions: insertions, + Deletions: deletions, + CommittedAt: time.Now().UTC(), } g.workspaceTracker.NotifyGitCommit(commit) @@ -830,9 +843,10 @@ func (g *GitOperator) Reset(ctx context.Context, commitSHA string, mode string) g.logger.Warn("failed to resolve HEAD after reset", zap.Error(headErr)) } else { reset := &streams.GitResetNotification{ - Timestamp: time.Now().UTC(), - PreviousHead: previousHead, - CurrentHead: strings.TrimSpace(newHead), + Timestamp: time.Now().UTC(), + RepositoryName: g.repoName, + PreviousHead: previousHead, + CurrentHead: strings.TrimSpace(newHead), } g.workspaceTracker.NotifyGitReset(reset) } diff --git a/apps/backend/internal/agentctl/server/process/git_log.go b/apps/backend/internal/agentctl/server/process/git_log.go index 06e02a988a..afd2d14bf3 100644 --- a/apps/backend/internal/agentctl/server/process/git_log.go +++ b/apps/backend/internal/agentctl/server/process/git_log.go @@ -11,6 +11,18 @@ type GitLogResult struct { Success bool `json:"success"` Commits []*GitCommitInfo `json:"commits"` Error string `json:"error,omitempty"` + // PerRepoErrors lists per-repo failures during a multi-repo log fan-out. + // Empty/nil for single-repo responses or when every repo succeeded. The + // merged response keeps Success=true as long as at least one repo + // succeeded; callers inspect this slice to surface partial failures. + PerRepoErrors []GitLogRepoError `json:"per_repo_errors,omitempty"` +} + +// GitLogRepoError describes a single per-repo failure from a multi-repo log +// fan-out. It is omitted from the response entirely for single-repo responses. +type GitLogRepoError struct { + RepositoryName string `json:"repository_name"` + Error string `json:"error"` } // GitCommitInfo represents a single commit in the log. @@ -25,6 +37,9 @@ type GitCommitInfo struct { FilesChanged int `json:"files_changed"` Insertions int `json:"insertions"` Deletions int `json:"deletions"` + // RepositoryName tags commits returned from a multi-repo log fan-out. + // Empty for single-repo workspaces. Set by the API layer after the call. + RepositoryName string `json:"repository_name,omitempty"` } // CumulativeDiffResult represents the cumulative diff from base commit to HEAD. diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index de8a24a926..04ebfa3857 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/adapter" "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/shell" + "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" tools "github.com/kandev/kandev/internal/tools/installer" @@ -84,6 +85,16 @@ type Manager struct { // Workspace tracker for git status and file changes workspaceTracker *WorkspaceTracker + // repoTrackers holds per-repository trackers for multi-repo task roots. + // Each tracker stamps RepositoryName onto its emitted events and shares + // subscriber channels with the root via the Manager fan-out. + // Empty for single-repo workspaces. + repoTrackers []*WorkspaceTracker + // workspaceTrackersBySubpath caches per-subpath trackers for multi-repo + // task roots. Key is the cleaned subpath (relative to cfg.WorkDir). The + // root tracker lives in workspaceTracker above. + workspaceTrackersBySubpath map[string]*WorkspaceTracker + workspaceTrackersMu sync.Mutex // Script/process runner (dev server, setup, cleanup, custom) processRunner *ProcessRunner @@ -112,6 +123,10 @@ type Manager struct { // Git operator for git operations (lazy-initialized) gitOperator *GitOperator gitOperatorMu sync.Mutex + // gitOperatorsBySubpath caches per-subpath operators for multi-repo task + // roots. Key is the cleaned subpath (relative to cfg.WorkDir); empty key + // is reserved for the root operator and lives in gitOperator above. + gitOperatorsBySubpath map[string]*GitOperator // Final command string (full command with all adapter args) finalCommand string @@ -130,10 +145,27 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { m := &Manager{ cfg: cfg, logger: log.WithFields(zap.String("component", "process-manager")), - workspaceTracker: NewWorkspaceTracker(cfg.WorkDir, log), updatesCh: make(chan adapter.AgentEvent, 100), pendingPermissions: make(map[string]*PendingPermission), } + // Multi-repo task roots hold one git worktree per repository as siblings. + // In that case build a per-repo tracker for each child so each emits its + // own GitStatusUpdate (tagged with RepositoryName) and the changes panel + // can show all repos. The root tracker covers the single-repo case via + // preferGitRepoChildIfRootIsBare; we skip its fallback when we've already + // detected a multi-repo root to avoid double-tracking the first repo. + repoChildren := scanRepositorySubdirs(cfg.WorkDir) + if len(repoChildren) >= 2 { + // Multi-repo: root tracker bound to the bare task root (no fallback, + // no events), plus one tracker per repo subdir. + m.workspaceTracker = NewWorkspaceTrackerForRepo(cfg.WorkDir, "", log) + for _, child := range repoChildren { + m.repoTrackers = append(m.repoTrackers, + NewWorkspaceTrackerForRepo(child.path, child.name, log)) + } + } else { + m.workspaceTracker = NewWorkspaceTracker(cfg.WorkDir, log) + } m.processRunner = NewProcessRunner(m.workspaceTracker, log, cfg.ProcessBufferMaxBytes) m.shellMgr = shell.NewManager(cfg.WorkDir, log) m.status.Store(StatusStopped) @@ -141,6 +173,38 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { return m } +// repositorySubdir is one git-repo child of a multi-repo task root. +type repositorySubdir struct { + name string // directory basename (used as RepositoryName on emitted events) + path string // absolute path to the repo subdir +} + +// scanRepositorySubdirs returns the immediate child directories of workDir +// that are themselves git repositories or worktrees. Returns an empty slice +// when workDir doesn't exist, isn't readable, or contains zero git children. +// Used to detect multi-repo task roots at Manager construction. +func scanRepositorySubdirs(workDir string) []repositorySubdir { + if workDir == "" { + return nil + } + entries, err := os.ReadDir(workDir) + if err != nil { + return nil + } + var out []repositorySubdir + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + candidate := filepath.Join(workDir, entry.Name()) + if resolveGitIndexPath(candidate) == "" { + continue + } + out = append(out, repositorySubdir{name: entry.Name(), path: candidate}) + } + return out +} + // Status returns the current process status func (m *Manager) Status() Status { return m.status.Load().(Status) @@ -166,6 +230,65 @@ func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker { return m.workspaceTracker } +// SubscribeWorkspaceStream creates a single workspace stream subscriber and +// fans it out across the root tracker plus every per-repo tracker, so the +// caller receives events from all repositories on one channel. Use +// UnsubscribeWorkspaceStream to detach and close. +// +// For single-repo workspaces this is equivalent to +// GetWorkspaceTracker().SubscribeWorkspaceStream() — the per-repo list is +// empty and only the root tracker fires events. +func (m *Manager) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber { + sub := make(types.WorkspaceStreamSubscriber, 100) + m.workspaceTracker.AttachWorkspaceStreamSubscriber(sub) + for _, t := range m.repoTrackers { + t.AttachWorkspaceStreamSubscriber(sub) + } + return sub +} + +// UnsubscribeWorkspaceStream detaches the subscriber from every tracker and +// closes the channel exactly once. +func (m *Manager) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) { + m.workspaceTracker.DetachWorkspaceStreamSubscriber(sub) + for _, t := range m.repoTrackers { + t.DetachWorkspaceStreamSubscriber(sub) + } + close(sub) +} + +// GetWorkspaceTrackerFor returns a workspace tracker scoped to a sub-directory +// of the workspace. Used by multi-repo task roots where each repository lives +// at {WorkDir}/{subpath}. Empty subpath returns the root tracker. +// +// Per-subpath trackers are created lazily on first request and cached. They +// are NOT started (no polling goroutines) — the multi-repo path uses them +// only for synchronous git-status queries via GetCurrentGitStatus, which +// falls through to getGitStatus when no cache is present. This keeps the +// per-repo cost cheap while preserving the long-running polling behavior of +// the root tracker for the agent's primary workspace. +func (m *Manager) GetWorkspaceTrackerFor(subpath string) (*WorkspaceTracker, error) { + cleaned, full, err := m.resolveSubpath(subpath) + if err != nil { + return nil, err + } + if cleaned == "" { + return m.workspaceTracker, nil + } + + m.workspaceTrackersMu.Lock() + defer m.workspaceTrackersMu.Unlock() + if m.workspaceTrackersBySubpath == nil { + m.workspaceTrackersBySubpath = make(map[string]*WorkspaceTracker) + } + if t, ok := m.workspaceTrackersBySubpath[cleaned]; ok { + return t, nil + } + t := NewWorkspaceTracker(full, m.logger) + m.workspaceTrackersBySubpath[cleaned] = t + return t, nil +} + // StartProcess runs a script/process with isolated stdout/stderr. func (m *Manager) StartProcess(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) { if m.processRunner == nil { @@ -198,8 +321,52 @@ func (m *Manager) ListProcesses(sessionID string) []ProcessInfo { return m.processRunner.List(sessionID) } -// GitOperator returns the git operator for git operations. -// The operator is lazy-initialized on first call. +// RepoSubpaths returns the subpath name (relative to cfg.WorkDir) for every +// per-repo tracker discovered at construction time. Empty for single-repo +// workspaces. Used by callers that want to fan an op out across repos. +func (m *Manager) RepoSubpaths() []string { + out := make([]string, 0, len(m.repoTrackers)) + for _, t := range m.repoTrackers { + if t.repositoryName != "" { + out = append(out, t.repositoryName) + } + } + return out +} + +// SetWorkspacePollMode propagates a poll-mode change to the root tracker and +// every per-repo tracker, then forces a RefreshGitStatus on each non-paused +// tracker so a fresh snapshot reaches every subscriber. Without the refresh, +// monitorTick only pushes on detected change — multi-repo workspaces would +// leave the per-repo state map sparse (one repo present, the other missing) +// after a focus event, since the agent's initial pushes happen at boot and +// no replay path exists for clients that subscribe later. +func (m *Manager) SetWorkspacePollMode(ctx context.Context, mode PollMode) { + m.workspaceTracker.SetPollMode(mode) + for _, t := range m.repoTrackers { + t.SetPollMode(mode) + } + if mode == PollModePaused { + return + } + // Snapshot the tracker slice before launching the goroutine so a + // concurrent Stop()/teardown that mutates m.repoTrackers can't race or + // nil-deref the iteration. Refresh in background — RefreshGitStatus + // blocks on git commands which can take seconds on large repos; the + // HTTP caller shouldn't wait. + root := m.workspaceTracker + trackers := make([]*WorkspaceTracker, len(m.repoTrackers)) + copy(trackers, m.repoTrackers) + go func() { + root.RefreshGitStatus(ctx) + for _, t := range trackers { + t.RefreshGitStatus(ctx) + } + }() +} + +// GitOperator returns the git operator for git operations against the +// workspace root. Lazy-initialized. func (m *Manager) GitOperator() *GitOperator { m.gitOperatorMu.Lock() defer m.gitOperatorMu.Unlock() @@ -210,6 +377,87 @@ func (m *Manager) GitOperator() *GitOperator { return m.gitOperator } +// GitOperatorFor returns a git operator scoped to a sub-directory of the +// workspace. Used by multi-repo task roots where each repository lives at +// {WorkDir}/{subpath}. Empty subpath returns the root operator. +// +// The subpath is validated to prevent path-traversal: it must be a clean +// relative path with no parent-references and must resolve to an existing +// directory inside cfg.WorkDir. +func (m *Manager) GitOperatorFor(subpath string) (*GitOperator, error) { + cleaned, full, err := m.resolveSubpath(subpath) + if err != nil { + return nil, err + } + if cleaned == "" { + return m.GitOperator(), nil + } + + m.gitOperatorMu.Lock() + defer m.gitOperatorMu.Unlock() + if m.gitOperatorsBySubpath == nil { + m.gitOperatorsBySubpath = make(map[string]*GitOperator) + } + if op, ok := m.gitOperatorsBySubpath[cleaned]; ok { + return op, nil + } + op := NewGitOperatorForRepo(full, cleaned, m.logger, m.workspaceTracker) + m.gitOperatorsBySubpath[cleaned] = op + return op, nil +} + +// resolveSubpath normalises and validates a repo subpath relative to +// cfg.WorkDir. Returns ("", "", nil) for the root (empty/"."); otherwise +// returns the cleaned relative path and the absolute full path. +// +// Rejects: parent-references, absolute paths, paths containing "..", +// and paths that don't resolve to an existing directory. +func (m *Manager) resolveSubpath(subpath string) (string, string, error) { + subpath = strings.TrimSpace(subpath) + if subpath == "" || subpath == "." { + return "", m.cfg.WorkDir, nil + } + + cleaned := filepath.Clean(subpath) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "..") { + return "", "", fmt.Errorf("invalid repo subpath: %q", subpath) + } + if filepath.IsAbs(cleaned) { + return "", "", fmt.Errorf("repo subpath must be relative: %q", subpath) + } + for _, part := range strings.Split(cleaned, string(filepath.Separator)) { + if part == ".." { + return "", "", fmt.Errorf("repo subpath escapes workspace: %q", subpath) + } + } + + full := filepath.Join(m.cfg.WorkDir, cleaned) + info, err := os.Stat(full) + if err != nil { + return "", "", fmt.Errorf("repo subpath not found: %w", err) + } + if !info.IsDir() { + return "", "", fmt.Errorf("repo subpath is not a directory: %q", subpath) + } + return cleaned, full, nil +} + +// JoinRepoPath validates a repo subpath and returns the workspace-relative +// path obtained by joining `subpath` and `path`. Empty `subpath` returns +// `path` unchanged (single-repo workspaces). Used by file content / update +// handlers to scope a per-repo path under the right repository directory +// before delegating to the workspace tracker. +func (m *Manager) JoinRepoPath(subpath, path string) (string, error) { + cleaned, _, err := m.resolveSubpath(subpath) + if err != nil { + return "", err + } + if cleaned == "" { + return path, nil + } + return filepath.Join(cleaned, path), nil +} + // Start starts the agent process func (m *Manager) Start(ctx context.Context) error { m.startMu.Lock() @@ -284,6 +532,9 @@ func (m *Manager) Start(ctx context.Context) error { // Start workspace tracker with background context (not tied to HTTP request) m.workspaceTracker.Start(context.Background()) + for _, t := range m.repoTrackers { + t.Start(context.Background()) + } // Auto-create shell session if enabled m.startAgentShell() @@ -306,6 +557,9 @@ func (m *Manager) startOneShot() error { // Start workspace tracker with background context (not tied to HTTP request) m.workspaceTracker.Start(context.Background()) + for _, t := range m.repoTrackers { + t.Start(context.Background()) + } // Auto-create shell session if enabled m.startAgentShell() @@ -661,6 +915,9 @@ func (m *Manager) stopShellAndProcesses(ctx context.Context) { if m.workspaceTracker != nil { m.workspaceTracker.Stop() } + for _, t := range m.repoTrackers { + t.Stop() + } m.logger.Debug("workspace tracker stopped") } diff --git a/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go b/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go new file mode 100644 index 0000000000..69df498630 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/manager_git_subpath_test.go @@ -0,0 +1,117 @@ +package process + +import ( + "os" + "path/filepath" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestManager(t *testing.T, workDir string) *Manager { + t.Helper() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + return NewManager(&config.InstanceConfig{WorkDir: workDir}, log) +} + +func TestManager_GitOperatorFor_EmptySubpathReturnsRoot(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + + op, err := mgr.GitOperatorFor("") + if err != nil { + t.Fatalf("empty subpath: %v", err) + } + if op == nil || op.workDir != tmp { + t.Errorf("expected root operator, got %+v", op) + } +} + +func TestManager_GitOperatorFor_ValidSubpathReturnsScopedOperator(t *testing.T) { + tmp := t.TempDir() + subDir := filepath.Join(tmp, "frontend") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + mgr := newTestManager(t, tmp) + + op, err := mgr.GitOperatorFor("frontend") + if err != nil { + t.Fatalf("subpath: %v", err) + } + if op.workDir != subDir { + t.Errorf("expected workDir %q, got %q", subDir, op.workDir) + } + + // Cached on second call. + op2, _ := mgr.GitOperatorFor("frontend") + if op2 != op { + t.Error("expected cached operator on second call") + } +} + +func TestManager_GitOperatorFor_RejectsPathTraversal(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + + cases := []string{"..", "../escape", "frontend/..", "/abs/path"} + for _, c := range cases { + if _, err := mgr.GitOperatorFor(c); err == nil { + t.Errorf("expected error for %q, got nil", c) + } + } +} + +func TestManager_GitOperatorFor_RejectsMissingDir(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + + if _, err := mgr.GitOperatorFor("does-not-exist"); err == nil { + t.Error("expected error for missing directory") + } +} + +func TestManager_GetWorkspaceTrackerFor_EmptySubpathReturnsRoot(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + + wt, err := mgr.GetWorkspaceTrackerFor("") + if err != nil { + t.Fatalf("empty subpath: %v", err) + } + if wt != mgr.GetWorkspaceTracker() { + t.Errorf("expected root tracker, got distinct instance") + } +} + +func TestManager_GetWorkspaceTrackerFor_ValidSubpathCachesPerSubpath(t *testing.T) { + tmp := t.TempDir() + subDir := filepath.Join(tmp, "frontend") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + mgr := newTestManager(t, tmp) + + wt1, err := mgr.GetWorkspaceTrackerFor("frontend") + if err != nil { + t.Fatalf("subpath: %v", err) + } + if wt1 == nil || wt1 == mgr.GetWorkspaceTracker() { + t.Errorf("expected dedicated subpath tracker, got root") + } + wt2, _ := mgr.GetWorkspaceTrackerFor("frontend") + if wt1 != wt2 { + t.Error("expected per-subpath tracker to be cached") + } +} + +func TestManager_GetWorkspaceTrackerFor_RejectsTraversal(t *testing.T) { + tmp := t.TempDir() + mgr := newTestManager(t, tmp) + for _, c := range []string{"..", "../escape", "/abs/path"} { + if _, err := mgr.GetWorkspaceTrackerFor(c); err == nil { + t.Errorf("expected error for %q, got nil", c) + } + } +} diff --git a/apps/backend/internal/agentctl/server/process/manager_multi_repo_stream_test.go b/apps/backend/internal/agentctl/server/process/manager_multi_repo_stream_test.go new file mode 100644 index 0000000000..8e863df276 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/manager_multi_repo_stream_test.go @@ -0,0 +1,140 @@ +package process + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/types" +) + +// Verifies the Manager fans out subscriptions across per-repo trackers and +// emits GitStatusUpdates tagged with RepositoryName for each repo in a +// multi-repo task root. This is the wire contract the frontend's +// per-repo Changes panel depends on. +func TestManager_SubscribeWorkspaceStream_MultiRepoEmitsPerRepoStatuses(t *testing.T) { + taskRoot := t.TempDir() + + // Build two real git repos as siblings under the task root. + for _, name := range []string{"frontend", "backend"} { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + dst := filepath.Join(taskRoot, name) + if err := os.Rename(repoDir, dst); err != nil { + t.Fatalf("place %s: %v", name, err) + } + } + + mgr := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t)) + if len(mgr.repoTrackers) != 2 { + t.Fatalf("expected 2 per-repo trackers, got %d", len(mgr.repoTrackers)) + } + + // Start the trackers so the subscriber's replayed status is non-empty. + mgr.workspaceTracker.Start(context.Background()) + for _, tr := range mgr.repoTrackers { + tr.Start(context.Background()) + } + t.Cleanup(func() { + mgr.workspaceTracker.Stop() + for _, tr := range mgr.repoTrackers { + tr.Stop() + } + }) + + sub := mgr.SubscribeWorkspaceStream() + defer mgr.UnsubscribeWorkspaceStream(sub) + + // On subscribe each tracker replays its current status. Drain up to + // 3 messages (root + 2 repos) and capture the per-repo names seen. + deadline := time.After(2 * time.Second) + seen := map[string]bool{} + for len(seen) < 2 { + select { + case msg := <-sub: + if msg.Type != types.WorkspaceMessageTypeGitStatus || msg.GitStatus == nil { + continue + } + if name := msg.GitStatus.RepositoryName; name != "" { + seen[name] = true + } + case <-deadline: + t.Fatalf("timed out waiting for per-repo status messages; got %v", seen) + } + } + if !seen["frontend"] || !seen["backend"] { + t.Errorf("expected both repos to emit; got %v", seen) + } +} + +// Single-repo workspaces must NOT spawn per-repo trackers — keeps the +// pre-multi-repo behavior verbatim and ensures GitStatusUpdate.RepositoryName +// stays empty for legacy clients. +func TestManager_SingleRepo_NoPerRepoTrackers(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + mgr := NewManager(&config.InstanceConfig{WorkDir: repoDir}, newTestLogger(t)) + if len(mgr.repoTrackers) != 0 { + t.Errorf("single-repo workspace should not have per-repo trackers; got %d", len(mgr.repoTrackers)) + } +} + +// RepoSubpaths must list every per-repo tracker's directory name (and +// nothing else) so the API layer can fan git-log out across repos. +func TestManager_RepoSubpaths(t *testing.T) { + taskRoot := t.TempDir() + for _, name := range []string{"frontend", "backend"} { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + if err := os.Rename(repoDir, filepath.Join(taskRoot, name)); err != nil { + t.Fatalf("place %s: %v", name, err) + } + } + + mgr := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t)) + subs := mgr.RepoSubpaths() + want := map[string]bool{"frontend": true, "backend": true} + if len(subs) != len(want) { + t.Fatalf("RepoSubpaths length = %d, want %d (got %v)", len(subs), len(want), subs) + } + for _, s := range subs { + if !want[s] { + t.Errorf("unexpected repo subpath %q", s) + } + } +} + +// Verifies that SetWorkspacePollMode propagates to every per-repo tracker. +// Without the fan-out, per-repo trackers stay at their construction-time +// default and never receive the focus-triggered refresh that delivers a +// fresh git status snapshot to a subscriber attached after agent boot. +func TestManager_SetWorkspacePollMode_PropagatesToPerRepoTrackers(t *testing.T) { + taskRoot := t.TempDir() + for _, name := range []string{"frontend", "backend"} { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + if err := os.Rename(repoDir, filepath.Join(taskRoot, name)); err != nil { + t.Fatalf("place %s: %v", name, err) + } + } + + mgr := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, newTestLogger(t)) + if len(mgr.repoTrackers) != 2 { + t.Fatalf("expected 2 per-repo trackers, got %d", len(mgr.repoTrackers)) + } + + mgr.SetWorkspacePollMode(context.Background(), PollModeSlow) + + if got := mgr.workspaceTracker.GetPollMode(); got != PollModeSlow { + t.Errorf("root tracker mode = %v, want %v", got, PollModeSlow) + } + for i, tr := range mgr.repoTrackers { + if got := tr.GetPollMode(); got != PollModeSlow { + t.Errorf("repoTrackers[%d] mode = %v, want %v", i, got, PollModeSlow) + } + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index ce0256462b..dab2327207 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -47,8 +47,9 @@ func (wt *WorkspaceTracker) updateFiles(ctx context.Context) { // getFileList retrieves the list of files in the workspace func (wt *WorkspaceTracker) getFileList(ctx context.Context) (types.FileListUpdate, error) { update := types.FileListUpdate{ - Timestamp: time.Now(), - Files: []types.FileEntry{}, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Files: []types.FileEntry{}, } // Use git ls-files to get tracked files AND untracked files (excluding ignored) diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_commits.go b/apps/backend/internal/agentctl/server/process/workspace_git_commits.go index 4e3d20f911..0121748df4 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_commits.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_commits.go @@ -71,16 +71,17 @@ func (wt *WorkspaceTracker) getCommitsSince(ctx context.Context, baseCommit stri } commits = append(commits, &types.GitCommitNotification{ - Timestamp: time.Now(), - CommitSHA: sha, - ParentSHA: parentSHA, - AuthorName: parts[2], - AuthorEmail: parts[3], - Message: parts[4], - FilesChanged: filesChanged, - Insertions: insertions, - Deletions: deletions, - CommittedAt: committedAt, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + CommitSHA: sha, + ParentSHA: parentSHA, + AuthorName: parts[2], + AuthorEmail: parts[3], + Message: parts[4], + FilesChanged: filesChanged, + Insertions: insertions, + Deletions: deletions, + CommittedAt: committedAt, }) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_poll.go b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go index 7f757d7d06..0dfa874725 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_poll.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go @@ -265,9 +265,10 @@ func (wt *WorkspaceTracker) checkGitChanges(ctx context.Context) { zap.String("previous", previousHead), zap.String("current", currentHead)) wt.notifyWorkspaceStreamGitReset(&types.GitResetNotification{ - Timestamp: time.Now(), - PreviousHead: previousHead, - CurrentHead: currentHead, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + PreviousHead: previousHead, + CurrentHead: currentHead, }) case !wt.isAncestor(ctx, previousHead, currentHead): // Case 2: History was rewritten - previousHead is not reachable from currentHead @@ -276,9 +277,10 @@ func (wt *WorkspaceTracker) checkGitChanges(ctx context.Context) { zap.String("previous", previousHead), zap.String("current", currentHead)) wt.notifyWorkspaceStreamGitReset(&types.GitResetNotification{ - Timestamp: time.Now(), - PreviousHead: previousHead, - CurrentHead: currentHead, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + PreviousHead: previousHead, + CurrentHead: currentHead, }) default: // Case 3: HEAD moved forward normally - get new commits @@ -321,6 +323,7 @@ func (wt *WorkspaceTracker) handleBranchSwitch(ctx context.Context, previousBran if baseCommit != "" { wt.notifyWorkspaceStreamBranchSwitch(&types.GitBranchSwitchNotification{ Timestamp: time.Now(), + RepositoryName: wt.repositoryName, PreviousBranch: previousBranch, CurrentBranch: currentBranch, CurrentHead: currentHead, diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_status.go b/apps/backend/internal/agentctl/server/process/workspace_git_status.go index 602f1cbccf..13a3660de4 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_status.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_status.go @@ -67,13 +67,24 @@ func (wt *WorkspaceTracker) GetCurrentGitStatus(ctx context.Context) (types.GitS // getGitStatus retrieves the current git status func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUpdate, error) { update := types.GitStatusUpdate{ - Timestamp: time.Now(), - Modified: []string{}, - Added: []string{}, - Deleted: []string{}, - Untracked: []string{}, - Renamed: []string{}, - Files: make(map[string]types.FileInfo), + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Modified: []string{}, + Added: []string{}, + Deleted: []string{}, + Untracked: []string{}, + Renamed: []string{}, + Files: make(map[string]types.FileInfo), + } + + // Bare trackers (multi-repo task roots) sit on a directory that isn't + // itself a git repo. Without this guard, `git status` would ascend the + // directory tree until it found a `.git` — for tasks nested inside a + // developer's own kandev checkout that lands on the OUTER worktree and + // silently emits its branch/ahead/behind as if it were the task. Bail + // out early to keep the bare tracker's `currentStatus` zero-valued. + if wt.gitIndexPath == "" { + return update, nil } if err := wt.getGitBranchInfo(ctx, &update); err != nil { diff --git a/apps/backend/internal/agentctl/server/process/workspace_monitor.go b/apps/backend/internal/agentctl/server/process/workspace_monitor.go index a3042c68bd..ef427cc51a 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_monitor.go +++ b/apps/backend/internal/agentctl/server/process/workspace_monitor.go @@ -190,8 +190,9 @@ func (wt *WorkspaceTracker) monitorTick(ctx context.Context, lastState *workspac wt.tryUpdateGitStatus(ctx) wt.updateFiles(ctx) wt.notifyWorkspaceStreamFileChange(types.FileChangeNotification{ - Timestamp: time.Now(), - Operation: types.FileOpRefresh, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Operation: types.FileOpRefresh, }) } return false @@ -353,12 +354,19 @@ func (wt *WorkspaceTracker) emitFileChanges(changes []types.FileChangeNotificati const maxSpecificChanges = 50 if len(changes) == 0 || len(changes) > maxSpecificChanges { wt.notifyWorkspaceStreamFileChange(types.FileChangeNotification{ - Timestamp: time.Now(), - Operation: types.FileOpRefresh, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Operation: types.FileOpRefresh, }) return } for i := range changes { + // Stamp repository name on every emitted notification when this tracker + // is scoped to a multi-repo subpath. Callers that build the slice may + // not know the tracker's repo, so we set it here as the single source. + if changes[i].RepositoryName == "" { + changes[i].RepositoryName = wt.repositoryName + } wt.notifyWorkspaceStreamFileChange(changes[i]) } } @@ -368,8 +376,9 @@ func (wt *WorkspaceTracker) emitFileChanges(changes []types.FileChangeNotificati // subscribers without waiting for the next poll cycle. func (wt *WorkspaceTracker) notifyFileChange(relPath, operation string) { wt.notifyWorkspaceStreamFileChange(types.FileChangeNotification{ - Timestamp: time.Now(), - Path: relPath, - Operation: operation, + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Path: relPath, + Operation: operation, }) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_stream.go b/apps/backend/internal/agentctl/server/process/workspace_stream.go index 8a12147e71..e976c3784e 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_stream.go +++ b/apps/backend/internal/agentctl/server/process/workspace_stream.go @@ -11,14 +11,33 @@ import ( // and sends current git status and file list immediately func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber { sub := make(types.WorkspaceStreamSubscriber, 100) + wt.AttachWorkspaceStreamSubscriber(sub) + return sub +} +// AttachWorkspaceStreamSubscriber registers an existing subscriber channel with +// this tracker and immediately replays the current git status. Used by the +// process Manager to fan out a single client subscription across multiple +// per-repo trackers (multi-repo task roots) without giving the client a +// channel per tracker. +func (wt *WorkspaceTracker) AttachWorkspaceStreamSubscriber(sub types.WorkspaceStreamSubscriber) { wt.workspaceSubMu.Lock() wt.workspaceStreamSubscribers[sub] = struct{}{} count := len(wt.workspaceStreamSubscribers) wt.workspaceSubMu.Unlock() wt.logger.Info("workspace stream subscriber added", zap.Int("subscribers", count)) - // Send current git status immediately + // Bare trackers (multi-repo task roots) don't track their own git state — + // `gitIndexPath == ""` means the workDir isn't itself a git repo, so any + // `currentStatus` here is either empty (initial) or stale from a previous + // shape of the tracker. Skip the replay so the subscriber doesn't see a + // ghost entry under the empty `repository_name`. + if wt.gitIndexPath == "" { + return + } + + // Replay current git status so the new subscriber doesn't have to wait for + // the next poll tick. wt.mu.RLock() currentStatus := wt.currentStatus wt.mu.RUnlock() @@ -26,23 +45,32 @@ func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubs if currentStatus.Timestamp.IsZero() { currentStatus.Timestamp = time.Now() } + if currentStatus.RepositoryName == "" { + currentStatus.RepositoryName = wt.repositoryName + } - // Send git status select { case sub <- types.NewWorkspaceGitStatus(¤tStatus): default: } - - return sub } -// UnsubscribeWorkspaceStream removes and closes a workspace stream subscriber +// UnsubscribeWorkspaceStream removes a workspace stream subscriber and closes +// the channel. Use DetachWorkspaceStreamSubscriber when the same channel is +// shared across multiple trackers — only the final detach should close it. func (wt *WorkspaceTracker) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) { + wt.DetachWorkspaceStreamSubscriber(sub) + close(sub) +} + +// DetachWorkspaceStreamSubscriber removes the subscriber from this tracker +// without closing its channel. Used by Manager.UnsubscribeWorkspaceStream +// which detaches from each tracker first and then closes once. +func (wt *WorkspaceTracker) DetachWorkspaceStreamSubscriber(sub types.WorkspaceStreamSubscriber) { wt.workspaceSubMu.Lock() delete(wt.workspaceStreamSubscribers, sub) count := len(wt.workspaceStreamSubscribers) wt.workspaceSubMu.Unlock() - close(sub) wt.logger.Info("workspace stream subscriber removed", zap.Int("subscribers", count)) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 2e6524dcfc..1a4aff635c 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -31,6 +31,11 @@ type WorkspaceTracker struct { workDir string gitIndexPath string // Cached, validated path to git index file (works with worktrees) logger *logger.Logger + // repositoryName identifies the repository this tracker covers when the + // agent's workspace is a multi-repo task root. Stamped onto every emitted + // GitStatusUpdate / FileListUpdate so the frontend can key per-repo state. + // Empty for the single-repo case. + repositoryName string // Current state currentStatus types.GitStatusUpdate @@ -82,9 +87,39 @@ type WorkspaceTracker struct { cancelFunc context.CancelFunc // Cancel function called during Stop } -// NewWorkspaceTracker creates a new workspace tracker +// NewWorkspaceTracker creates a new workspace tracker for a single git +// repository (or a single workspace root that may or may not be a git repo). +// For multi-repo task roots use NewWorkspaceTrackerForRepo per repo subdir. func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { - resolvedWorkDir := resolveExistingWorkDir(workDir, log.WithFields(zap.String("component", "workspace-tracker"))) + tlog := log.WithFields(zap.String("component", "workspace-tracker")) + resolvedWorkDir := resolveExistingWorkDir(workDir, tlog) + // Multi-repo task roots are plain directories that hold one git worktree + // per repository as siblings. The git poller can't monitor a non-git path, + // so when the configured workDir isn't a git repo, fall back to the first + // child subdirectory that is. This preserves single-repo behavior for + // callers using NewWorkspaceTracker; multi-repo callers should use + // NewWorkspaceTrackerForRepo per repo subdir to get per-repo events. + resolvedWorkDir = preferGitRepoChildIfRootIsBare(resolvedWorkDir, tlog) + return newWorkspaceTracker(resolvedWorkDir, "", log) +} + +// NewWorkspaceTrackerForRepo creates a tracker scoped to a specific repository +// subdirectory. The repositoryName is stamped onto every emitted event so the +// frontend can route updates per repo for multi-repo task roots. +func NewWorkspaceTrackerForRepo(workDir, repositoryName string, log *logger.Logger) *WorkspaceTracker { + tlog := log.WithFields( + zap.String("component", "workspace-tracker"), + zap.String("repository_name", repositoryName), + ) + resolvedWorkDir := resolveExistingWorkDir(workDir, tlog) + return newWorkspaceTracker(resolvedWorkDir, repositoryName, log) +} + +func newWorkspaceTracker(resolvedWorkDir, repositoryName string, log *logger.Logger) *WorkspaceTracker { + logFields := []zap.Field{zap.String("component", "workspace-tracker")} + if repositoryName != "" { + logFields = append(logFields, zap.String("repository_name", repositoryName)) + } // Cache validated git index path (works with worktrees where .git is a file) gitIndexPath := resolveGitIndexPath(resolvedWorkDir) @@ -94,7 +129,8 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { return &WorkspaceTracker{ workDir: resolvedWorkDir, gitIndexPath: gitIndexPath, - logger: log.WithFields(zap.String("component", "workspace-tracker")), + repositoryName: repositoryName, + logger: log.WithFields(logFields...), workspaceStreamSubscribers: make(map[types.WorkspaceStreamSubscriber]struct{}), filePollInterval: DefaultFilePollInterval, gitPollInterval: DefaultGitPollInterval, @@ -117,10 +153,56 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { } } +// preferGitRepoChildIfRootIsBare returns workDir unchanged when it's already a +// git repo, otherwise returns the path of the first immediate child directory +// that is a git repo (or worktree). Used to make the workspace tracker work +// for multi-repo task roots, which are plain holder directories with each +// repo's worktree as a child. +// +// Returns workDir unchanged when no git child is found — the tracker will +// then run with no git data, matching the pre-multi-repo behavior for +// repo-less tasks (e.g. quick chat). +func preferGitRepoChildIfRootIsBare(workDir string, log *logger.Logger) string { + if workDir == "" || resolveGitIndexPath(workDir) != "" { + return workDir + } + entries, err := os.ReadDir(workDir) + if err != nil { + return workDir + } + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + candidate := filepath.Join(workDir, entry.Name()) + if resolveGitIndexPath(candidate) != "" { + log.Info("workspace tracker falling back to repo subdirectory (multi-repo task root is not itself a git repo)", + zap.String("task_root", workDir), + zap.String("repo_subdir", candidate)) + return candidate + } + } + return workDir +} + // resolveGitIndexPath returns the validated path to the git index file. // Returns empty string if the path cannot be resolved or validated. // This handles worktrees where .git is a file pointing elsewhere. +// +// Multi-repo task roots are NOT git repos themselves (they hold per-repo +// child worktrees as siblings), but `git rev-parse` ascends until it finds +// a `.git` — for tasks nested under a developer's own kandev checkout this +// would land on the OUTER worktree and silently emit its status as if it +// were the task. We guard against that by requiring the resolved git +// top-level to be the same path as workDir (or, for repo subdirs called +// here from `scanRepositorySubdirs`, an absolute git-dir reachable from the +// dir's own `.git` file). In practice this means: workDir must contain its +// own `.git` entry — file or directory — for the path to be considered a +// valid git repo for tracking. func resolveGitIndexPath(workDir string) string { + if !workDirHasOwnGitEntry(workDir) { + return "" + } cmd := exec.Command("git", "rev-parse", "--git-dir") cmd.Dir = workDir out, err := cmd.Output() @@ -142,6 +224,20 @@ func resolveGitIndexPath(workDir string) string { return indexPath } +// workDirHasOwnGitEntry returns true when workDir contains a `.git` entry of +// its own (file for worktrees, directory for plain repos). This is what +// makes a directory "a git repo" from a tracker's perspective — without it, +// git would ascend up the tree to the nearest ancestor `.git`, which is the +// wrong scope for nested layouts (e.g. a multi-repo task root sitting +// inside the developer's kandev checkout). +func workDirHasOwnGitEntry(workDir string) bool { + if workDir == "" { + return false + } + _, err := os.Lstat(filepath.Join(workDir, ".git")) + return err == nil +} + // workDirExists checks whether the workspace directory still exists on disk. // Used to detect deleted worktrees and stop polling loops gracefully. // The workDir is validated at construction time via resolveExistingWorkDir, diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go new file mode 100644 index 0000000000..4ed9b9df49 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_multi_repo_test.go @@ -0,0 +1,73 @@ +package process + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// TestWorkspaceTracker_FallsBackToRepoSubdir verifies the fix for the +// "changes panel empty on multi-repo task" issue. When the agent's workspace +// path is the multi-repo task root (a plain holder directory), the workspace +// tracker should fall back to the first git-repo child so it can still emit +// git status updates for at least one repo. +func TestWorkspaceTracker_FallsBackToRepoSubdir(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + // Build a synthetic task root that holds the test repo as a real + // sub-directory, matching what env_preparer creates for multi-repo tasks + // (worktrees placed under ~/.kandev/tasks/{TaskDirName}/{RepoName}/). + // We rename rather than symlink because the production layout uses real + // directories — and os.ReadDir's IsDir() returns false for symlinks. + taskRoot := t.TempDir() + repoSubdir := filepath.Join(taskRoot, "frontend") + if err := os.Rename(repoDir, repoSubdir); err != nil { + t.Fatalf("rename: %v", err) + } + + wt := NewWorkspaceTracker(taskRoot, newTestLogger(t)) + + // The tracker must have resolved its workDir to the repo subdir, otherwise + // getGitStatus would fail with "not a git repository". + if wt.workDir == taskRoot { + t.Fatalf("expected workDir to fall back to %q, got the bare task root %q", repoSubdir, taskRoot) + } + + status, err := wt.getGitStatus(context.Background()) + if err != nil { + t.Fatalf("getGitStatus on resolved subdir: %v", err) + } + if status.Branch == "" { + t.Errorf("expected non-empty branch from the fallback subdir; got status=%+v", status) + } +} + +// TestWorkspaceTracker_NoFallbackForRealRepo verifies the fallback only kicks +// in when the work dir itself is non-git. Single-repo tasks must keep their +// previous behavior verbatim. +func TestWorkspaceTracker_NoFallbackForRealRepo(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + wt := NewWorkspaceTracker(repoDir, newTestLogger(t)) + if wt.workDir != repoDir { + t.Errorf("real git repo workDir should not be rewritten; got %q want %q", wt.workDir, repoDir) + } +} + +// TestWorkspaceTracker_NoFallbackWhenNoGitChild verifies that a truly empty +// task root (no git children) doesn't try to fall back to anything — the +// tracker keeps the original workDir and just emits no git data. +func TestWorkspaceTracker_NoFallbackWhenNoGitChild(t *testing.T) { + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "not-a-repo"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + wt := NewWorkspaceTracker(tmp, newTestLogger(t)) + if wt.workDir != tmp { + t.Errorf("expected workDir unchanged when no git child exists; got %q", wt.workDir) + } +} diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go index e156e2430a..078dc63435 100644 --- a/apps/backend/internal/agentctl/types/streams/file.go +++ b/apps/backend/internal/agentctl/types/streams/file.go @@ -31,6 +31,11 @@ type FileChangeNotification struct { // Timestamp is when the change was detected. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository emitted this file change in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so per-repo views can scope refresh signals correctly. + RepositoryName string `json:"repository_name,omitempty"` + // Path is the file path relative to workspace root. Path string `json:"path"` @@ -45,6 +50,10 @@ type FileListUpdate struct { // Timestamp is when the listing was captured. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this list belongs to in + // multi-repo workspaces. Empty for single-repo. See GitStatusUpdate. + RepositoryName string `json:"repository_name,omitempty"` + // Files contains the list of files in the workspace. Files []FileEntry `json:"files"` } @@ -163,9 +172,14 @@ type FileSearchResponse struct { // // HTTP endpoint: POST /api/v1/workspace/file/content type FileUpdateRequest struct { - // Path is the file path (relative to workspace root). + // Path is the file path (relative to the per-repo subpath when Repo is + // set, otherwise relative to the workspace root). Path string `json:"path"` + // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo + // workspaces. When set, Path is interpreted relative to /. + Repo string `json:"repo,omitempty"` + // Diff is the unified diff to apply to the file. Diff string `json:"diff"` @@ -205,8 +219,13 @@ type FileUpdateResponse struct { // // HTTP endpoint: POST /api/v1/workspace/file/create type FileCreateRequest struct { - // Path is the file path (relative to workspace root). + // Path is the file path (relative to the per-repo subpath when Repo is + // set, otherwise relative to the workspace root). Path string `json:"path"` + + // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo + // workspaces. When set, Path is interpreted relative to /. + Repo string `json:"repo,omitempty"` } // FileCreateResponse represents a response to a file create request. @@ -248,11 +267,16 @@ type FileDeleteResponse struct { // // HTTP endpoint: POST /api/v1/workspace/file/rename type FileRenameRequest struct { - // OldPath is the current path (relative to workspace root). + // OldPath is the current path (relative to the per-repo subpath when + // Repo is set, otherwise relative to the workspace root). OldPath string `json:"old_path"` - // NewPath is the new path (relative to workspace root). + // NewPath is the new path (same scoping rules as OldPath). NewPath string `json:"new_path"` + + // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo + // workspaces. Both OldPath and NewPath are scoped to this repo. + Repo string `json:"repo,omitempty"` } // FileRenameResponse represents a response to a rename request. diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go index 1bd6e1cf7d..ed6284ea13 100644 --- a/apps/backend/internal/agentctl/types/streams/git.go +++ b/apps/backend/internal/agentctl/types/streams/git.go @@ -10,6 +10,12 @@ type GitStatusUpdate struct { // Timestamp is when this status was captured. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this status belongs to when + // the agent's workspace contains multiple git repos as siblings (multi-repo + // task roots). Empty for single-repo workspaces. Frontends key per-repo + // state on this name. + RepositoryName string `json:"repository_name,omitempty"` + // Modified contains paths of modified files. Modified []string `json:"modified"` @@ -89,6 +95,11 @@ type GitCommitNotification struct { // Timestamp is when this notification was created. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this commit belongs to in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so the Commits panel can render per-repo group headers. + RepositoryName string `json:"repository_name,omitempty"` + // CommitSHA is the SHA of the new commit. CommitSHA string `json:"commit_sha"` @@ -123,6 +134,11 @@ type GitResetNotification struct { // Timestamp is when this notification was created. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this reset belongs to in + // multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so the Changes panel can scope per-repo group state. + RepositoryName string `json:"repository_name,omitempty"` + // PreviousHead is the SHA HEAD was pointing to before the reset. PreviousHead string `json:"previous_head"` @@ -136,6 +152,11 @@ type GitBranchSwitchNotification struct { // Timestamp is when this notification was created. Timestamp time.Time `json:"timestamp"` + // RepositoryName identifies which repository this branch switch belongs to + // in multi-repo task workspaces. Empty for single-repo. Carried through to + // the frontend so the Changes panel can update the matching per-repo group. + RepositoryName string `json:"repository_name,omitempty"` + // PreviousBranch is the branch name before the switch. PreviousBranch string `json:"previous_branch"` diff --git a/apps/backend/internal/github/handlers.go b/apps/backend/internal/github/handlers.go index d0b9a58e68..27b6d71c6b 100644 --- a/apps/backend/internal/github/handlers.go +++ b/apps/backend/internal/github/handlers.go @@ -216,9 +216,21 @@ func wsGetTaskPR(svc *Service, _ *logger.Logger) func(ctx context.Context, msg * } } +// wsSyncTaskPR returns ALL PR rows for a task — multi-repo tasks have one +// per repo. Single-repo callers can read `prs[0]` (or treat empty as "no +// PR yet"); multi-repo callers iterate and call setTaskPR for each so the +// per-repo PR icon stays in sync. The legacy single-PR shape would have +// silently dropped every repo's PR except the most-recently-updated one. func wsSyncTaskPR(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return wsWithField("task_id", func(ctx context.Context, taskID string) (interface{}, error) { - return svc.TriggerPRSync(ctx, taskID) + prs, err := svc.TriggerPRSyncAll(ctx, taskID) + if err != nil { + return nil, err + } + // Return an envelope so the frontend always gets a deterministic + // shape even on empty results (`{prs: []}`); a bare `nil` would + // confuse the WS handler's success/error branching. + return map[string]interface{}{"prs": prs}, nil }) } diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index ce85317f4e..deea89d747 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -113,11 +113,14 @@ type IssueSearchPage struct { PerPage int `json:"per_page"` } -// PRWatch tracks active PR monitoring (session → PR). +// PRWatch tracks active PR monitoring (session → PR). RepositoryID identifies +// which task repository the watched PR belongs to (multi-repo support; empty +// for legacy rows). type PRWatch struct { ID string `json:"id" db:"id"` SessionID string `json:"session_id" db:"session_id"` TaskID string `json:"task_id" db:"task_id"` + RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` Owner string `json:"owner" db:"owner"` Repo string `json:"repo" db:"repo"` PRNumber int `json:"pr_number" db:"pr_number"` @@ -130,10 +133,13 @@ type PRWatch struct { UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } -// TaskPR associates a PR with a task. +// TaskPR associates a PR with a task. RepositoryID identifies which task +// repository this PR belongs to (multi-repo tasks can have one PR per repo). +// Empty for legacy rows persisted before multi-repo support. type TaskPR struct { ID string `json:"id" db:"id"` TaskID string `json:"task_id" db:"task_id"` + RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` Owner string `json:"owner" db:"owner"` Repo string `json:"repo" db:"repo"` PRNumber int `json:"pr_number" db:"pr_number"` diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go index 6d7a72cb8a..14e45a711e 100644 --- a/apps/backend/internal/github/poller.go +++ b/apps/backend/internal/github/poller.go @@ -19,12 +19,15 @@ const ( ) // TaskBranchInfo describes a task+session that may need a PR watch. +// RepositoryID scopes multi-repo tasks: each repo is a separate watch row. +// Empty for legacy single-repo tasks. type TaskBranchInfo struct { - TaskID string - SessionID string - Owner string - Repo string - Branch string + TaskID string + SessionID string + RepositoryID string + Owner string + Repo string + Branch string } // TaskBranchProvider lists tasks that should have PR watches and resolves branches. @@ -202,7 +205,7 @@ func (p *Poller) detectPRForWatch(ctx context.Context, watch *PRWatch) { return } - if _, assocErr := p.service.AssociatePRWithTask(ctx, watch.TaskID, pr); assocErr != nil { + if _, assocErr := p.service.AssociatePRWithTask(ctx, watch.TaskID, watch.RepositoryID, pr); assocErr != nil { p.logger.Error("failed to associate detected PR with task", zap.String("task_id", watch.TaskID), zap.Int("pr_number", pr.Number), @@ -258,7 +261,7 @@ func (p *Poller) reconcileWatches(ctx context.Context) { } for _, task := range tasks { if _, ensureErr := p.service.EnsurePRWatch( - ctx, task.SessionID, task.TaskID, task.Owner, task.Repo, task.Branch, + ctx, task.SessionID, task.TaskID, task.RepositoryID, task.Owner, task.Repo, task.Branch, ); ensureErr != nil { p.logger.Error("failed to ensure PR watch", zap.String("session_id", task.SessionID), zap.Error(ensureErr)) diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 63e81e203b..02d25f713c 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -301,37 +301,59 @@ func (s *Service) SubmitReview(ctx context.Context, owner, repo string, number i // --- PR Watch operations --- -// CreatePRWatch creates a new PR watch for a session. -func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, owner, repo string, prNumber int, branch string) (*PRWatch, error) { - existing, err := s.store.GetPRWatchBySession(ctx, sessionID) +// CreatePRWatch creates a new PR watch for a (session, repository) pair. +// `repositoryID` may be empty for legacy single-repo callers; multi-repo +// callers must pass the per-task repository_id so each repo gets its own +// watch row. +func (s *Service) CreatePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*PRWatch, error) { + existing, err := s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) if err != nil { return nil, err } if existing != nil { - return existing, nil // already watching + return existing, nil // already watching this (session, repo) } w := &PRWatch{ - SessionID: sessionID, - TaskID: taskID, - Owner: owner, - Repo: repo, - PRNumber: prNumber, - Branch: branch, + SessionID: sessionID, + TaskID: taskID, + RepositoryID: repositoryID, + Owner: owner, + Repo: repo, + PRNumber: prNumber, + Branch: branch, } if err := s.store.CreatePRWatch(ctx, w); err != nil { return nil, fmt.Errorf("create PR watch: %w", err) } s.logger.Info("created PR watch", zap.String("session_id", sessionID), + zap.String("repository_id", repositoryID), zap.Int("pr_number", prNumber)) return w, nil } -// GetPRWatchBySession returns the PR watch for a session. +// GetPRWatchBySession returns the first PR watch for a session. Multi-repo +// callers should prefer GetPRWatchBySessionAndRepo to avoid landing on the +// wrong repo's watch. func (s *Service) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRWatch, error) { return s.store.GetPRWatchBySession(ctx, sessionID) } +// GetPRWatchBySessionAndRepo returns the PR watch for a (session, repo) pair. +func (s *Service) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*PRWatch, error) { + return s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) +} + +// ListPRWatchesBySession returns every PR watch for a session. +func (s *Service) ListPRWatchesBySession(ctx context.Context, sessionID string) ([]*PRWatch, error) { + return s.store.ListPRWatchesBySession(ctx, sessionID) +} + +// ListPRWatchesByTask returns every PR watch for a task. +func (s *Service) ListPRWatchesByTask(ctx context.Context, taskID string) ([]*PRWatch, error) { + return s.store.ListPRWatchesByTask(ctx, taskID) +} + // ListActivePRWatches returns all active PR watches. func (s *Service) ListActivePRWatches(ctx context.Context) ([]*PRWatch, error) { return s.store.ListActivePRWatches(ctx) @@ -387,10 +409,14 @@ func (s *Service) CheckPRWatch(ctx context.Context, watch *PRWatch) (*PRStatus, return status, hasNew, nil } -// EnsurePRWatch creates a PRWatch with pr_number=0 for a session if one doesn't already exist. -// The poller will detect the PR by searching for the branch on GitHub. -func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, repo, branch string) (*PRWatch, error) { - existing, err := s.store.GetPRWatchBySession(ctx, sessionID) +// EnsurePRWatch creates a PRWatch with pr_number=0 for a (session, repo) pair +// if one doesn't already exist. The poller will detect the PR by searching +// for the branch on GitHub. `repositoryID` is empty for legacy single-repo +// callers; multi-repo callers MUST pass the per-task repository_id so each +// repo gets its own watch (the table's UNIQUE(session_id, repository_id) used +// to be UNIQUE(session_id), which silently dropped second-repo watches). +func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo, branch string) (*PRWatch, error) { + existing, err := s.store.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) if err != nil { return nil, err } @@ -398,27 +424,36 @@ func (s *Service) EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, r return existing, nil } w := &PRWatch{ - SessionID: sessionID, - TaskID: taskID, - Owner: owner, - Repo: repo, - PRNumber: 0, - Branch: branch, + SessionID: sessionID, + TaskID: taskID, + RepositoryID: repositoryID, + Owner: owner, + Repo: repo, + PRNumber: 0, + Branch: branch, } if err := s.store.CreatePRWatch(ctx, w); err != nil { return nil, fmt.Errorf("ensure PR watch: %w", err) } s.logger.Info("created PR watch for session (will search for PR)", zap.String("session_id", sessionID), + zap.String("repository_id", repositoryID), zap.String("branch", branch)) return w, nil } // --- Task-PR association --- -// AssociatePRWithTask creates a task-PR association. -func (s *Service) AssociatePRWithTask(ctx context.Context, taskID string, pr *PR) (*TaskPR, error) { - existing, err := s.store.GetTaskPR(ctx, taskID) +// AssociatePRWithTask creates a task-PR association scoped to a specific +// repository. `repositoryID` is the per-task repository_id (from +// task_repositories); empty preserves legacy single-repo behavior. Multi-repo +// callers MUST pass it — empty causes ReplaceTaskPR to wipe the entire task's +// PR rows (legacy "delete all" branch), which is what older code relied on. +func (s *Service) AssociatePRWithTask(ctx context.Context, taskID, repositoryID string, pr *PR) (*TaskPR, error) { + // Check for an existing PR for this exact (task, repo). Multi-repo callers + // must scope by repository_id so the same PR number in two repos doesn't + // short-circuit the second association. + existing, err := s.store.GetTaskPRByRepository(ctx, taskID, repositoryID) if err != nil { return nil, err } @@ -426,32 +461,34 @@ func (s *Service) AssociatePRWithTask(ctx context.Context, taskID string, pr *PR return existing, nil } tp := &TaskPR{ - TaskID: taskID, - Owner: pr.RepoOwner, - Repo: pr.RepoName, - PRNumber: pr.Number, - PRURL: pr.HTMLURL, - PRTitle: pr.Title, - HeadBranch: pr.HeadBranch, - BaseBranch: pr.BaseBranch, - AuthorLogin: pr.AuthorLogin, - State: pr.State, - Additions: pr.Additions, - Deletions: pr.Deletions, - CreatedAt: pr.CreatedAt, - MergedAt: pr.MergedAt, - ClosedAt: pr.ClosedAt, + TaskID: taskID, + RepositoryID: repositoryID, + Owner: pr.RepoOwner, + Repo: pr.RepoName, + PRNumber: pr.Number, + PRURL: pr.HTMLURL, + PRTitle: pr.Title, + HeadBranch: pr.HeadBranch, + BaseBranch: pr.BaseBranch, + AuthorLogin: pr.AuthorLogin, + State: pr.State, + Additions: pr.Additions, + Deletions: pr.Deletions, + CreatedAt: pr.CreatedAt, + MergedAt: pr.MergedAt, + ClosedAt: pr.ClosedAt, } - // ReplaceTaskPR atomically deletes any existing association for the task - // and inserts the new row inside a single transaction. This preserves the - // effective 1:1 task→PR mapping and prevents a window where the task has - // no associated PR or concurrent calls produce duplicate rows. + // ReplaceTaskPR atomically deletes any existing association for the + // (task, repository) pair and inserts the new row inside one transaction. + // Scoping by repository_id keeps multi-repo tasks intact; legacy callers + // (repositoryID == "") still get the "delete all" semantics. if err := s.store.ReplaceTaskPR(ctx, tp); err != nil { return nil, fmt.Errorf("replace task PR: %w", err) } if existing != nil { s.logger.Info("replaced stale task PR association", zap.String("task_id", taskID), + zap.String("repository_id", repositoryID), zap.Int("old_pr_number", existing.PRNumber), zap.Int("new_pr_number", pr.Number)) } @@ -466,13 +503,18 @@ func (s *Service) AssociatePRWithTask(ctx context.Context, taskID string, pr *PR s.logger.Info("associated PR with task", zap.String("task_id", taskID), + zap.String("repository_id", repositoryID), zap.Int("pr_number", pr.Number)) return tp, nil } -// AssociatePRByURL parses a GitHub PR URL, fetches the PR data, creates a PR watch, -// and associates it with the given task. Called after user creates a PR from the UI. -func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, prURL, branch string) { +// AssociatePRByURL parses a GitHub PR URL, fetches the PR data, creates a PR +// watch, and associates it with the given task. Called after the user +// creates a PR from the UI. `repositoryID` scopes the watch + association to +// a specific per-task repository (multi-repo tasks); empty preserves the +// legacy single-repo behavior. Without this, the second repo's UI-initiated +// PR would overwrite the first's TaskPR row. +func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, repositoryID, prURL, branch string) { if s.client == nil { return } @@ -493,13 +535,13 @@ func (s *Service) AssociatePRByURL(ctx context.Context, sessionID, taskID, prURL if branch == "" { branch = pr.HeadBranch } - if _, watchErr := s.CreatePRWatch(ctx, sessionID, taskID, owner, repo, prNumber, branch); watchErr != nil { + if _, watchErr := s.CreatePRWatch(ctx, sessionID, taskID, repositoryID, owner, repo, prNumber, branch); watchErr != nil { s.logger.Error("failed to create PR watch after PR creation", zap.String("session_id", sessionID), zap.Error(watchErr)) } // Associate PR with task (persists + publishes WS event) - if _, assocErr := s.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil { + if _, assocErr := s.AssociatePRWithTask(ctx, taskID, repositoryID, pr); assocErr != nil { s.logger.Error("failed to associate PR with task after creation", zap.String("task_id", taskID), zap.Error(assocErr)) } @@ -549,26 +591,37 @@ func (s *Service) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) return s.store.GetTaskPR(ctx, taskID) } -// ListTaskPRs returns PR associations for multiple tasks. -func (s *Service) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string]*TaskPR, error) { +// ListTaskPRs returns PR associations for multiple tasks, grouped by task_id. +// Multi-repo tasks may have more than one PR per task. +func (s *Service) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string][]*TaskPR, error) { return s.store.ListTaskPRsByTaskIDs(ctx, taskIDs) } -// ListWorkspaceTaskPRs returns all PR associations for a workspace. -// It returns cached data immediately and triggers background refresh for stale entries. -func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) (map[string]*TaskPR, error) { +// ListWorkspaceTaskPRs returns all PR associations for a workspace, grouped by +// task_id. Multi-repo tasks may have more than one PR per task. It returns +// cached data immediately and triggers background refresh for stale entries. +func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) (map[string][]*TaskPR, error) { result, err := s.store.ListTaskPRsByWorkspaceID(ctx, workspaceID) if err != nil { return nil, err } - // Collect stale task IDs for background refresh - var staleTaskIDs []string - for _, tp := range result { - if tp.LastSyncedAt == nil || time.Since(*tp.LastSyncedAt) >= prSyncFreshnessWindow { - staleTaskIDs = append(staleTaskIDs, tp.TaskID) + // Collect stale task IDs for background refresh. A task is considered stale + // if any of its PRs are stale; the sync is per-task so we only need to + // queue each task once. + staleTasks := make(map[string]struct{}) + for taskID, prs := range result { + for _, tp := range prs { + if tp.LastSyncedAt == nil || time.Since(*tp.LastSyncedAt) >= prSyncFreshnessWindow { + staleTasks[taskID] = struct{}{} + break + } } } + staleTaskIDs := make([]string, 0, len(staleTasks)) + for id := range staleTasks { + staleTaskIDs = append(staleTaskIDs, id) + } // Background refresh with bounded concurrency if len(staleTaskIDs) > 0 { @@ -580,7 +633,7 @@ func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) defer func() { <-sem }() syncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if _, syncErr := s.TriggerPRSync(syncCtx, id); syncErr != nil { + if _, syncErr := s.TriggerPRSyncAll(syncCtx, id); syncErr != nil { s.logger.Debug("background PR sync failed", zap.String("task_id", id), zap.Error(syncErr)) } }(taskID) @@ -591,14 +644,35 @@ func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) return result, nil } -// SyncTaskPR updates a TaskPR record with the latest PR status. +// findTaskPRForStatus locates the TaskPR row matching the (task, owner, repo, +// pr_number) tuple from a poll result. Multi-repo tasks can have multiple +// rows for the same task — narrowing by (owner, repo, pr_number) ensures the +// caller updates the right one. Returns nil (no error) when no row exists, +// matching the prior GetTaskPR semantics. +func (s *Service) findTaskPRForStatus(ctx context.Context, taskID string, pr *PR) (*TaskPR, error) { + rows, err := s.store.ListTaskPRsByTask(ctx, taskID) + if err != nil { + return nil, err + } + for _, tp := range rows { + if tp.Owner == pr.RepoOwner && tp.Repo == pr.RepoName && tp.PRNumber == pr.Number { + return tp, nil + } + } + return nil, nil +} + +// SyncTaskPR updates a TaskPR record with the latest PR status. Multi-repo: +// the row is found by (task_id, owner, repo, pr_number) since the same +// task can have several PRs; the legacy GetTaskPR(taskID) "first match" +// would cross repos and silently update the wrong row. // It only publishes a github.task_pr.updated event when data actually changed, // preventing feedback loops with frontend sync handlers. func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatus) error { if status == nil || status.PR == nil { return fmt.Errorf("sync task PR: missing PR data for task %s", taskID) } - tp, err := s.store.GetTaskPR(ctx, taskID) + tp, err := s.findTaskPRForStatus(ctx, taskID, status.PR) if err != nil || tp == nil { return err } @@ -749,10 +823,10 @@ func (s *Service) GetPRStatusesBatch(ctx context.Context, refs []PRRef) (map[str return result, nil } -// TriggerPRSync performs an immediate PR status sync for a task. -// If the watch has a PR number, it fetches the latest status from GitHub -// and syncs it to the TaskPR record. If still searching (pr_number=0), -// it attempts to find the PR by branch. +// TriggerPRSync performs an immediate PR status sync for a task. Single-repo +// callers see the same single-PR contract as before. Multi-repo callers get +// the primary repo's PR back; they should use TriggerPRSyncAll to refresh +// every repo's PR in one round-trip. func (s *Service) TriggerPRSync(ctx context.Context, taskID string) (*TaskPR, error) { watch, err := s.store.GetPRWatchByTask(ctx, taskID) if err != nil { @@ -770,6 +844,51 @@ func (s *Service) TriggerPRSync(ctx context.Context, taskID string) (*TaskPR, er return s.triggerPRStatusSync(ctx, watch, taskID) } +// TriggerPRSyncAll performs an immediate PR status sync for every PR watch +// associated with the task and returns every resulting TaskPR. For +// multi-repo tasks this is the right entry point — TriggerPRSync only +// touches the most recently updated watch and silently leaves the other +// repos' PRs stale. Returns an empty slice (not nil) when the task has no +// watches. +func (s *Service) TriggerPRSyncAll(ctx context.Context, taskID string) ([]*TaskPR, error) { + watches, err := s.store.ListPRWatchesByTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("list PR watches: %w", err) + } + if len(watches) == 0 { + // No watches — fall back to whatever TaskPRs already exist (e.g. + // PRs imported via task-create-from-PR-URL where the watch is + // optional). Empty slice if none. + existing, listErr := s.store.ListTaskPRsByTask(ctx, taskID) + if listErr != nil { + return nil, fmt.Errorf("list task PRs: %w", listErr) + } + return existing, nil + } + results := make([]*TaskPR, 0, len(watches)) + for _, w := range watches { + var tp *TaskPR + var syncErr error + if w.PRNumber == 0 { + tp, syncErr = s.triggerPRDetection(ctx, w, taskID) + } else { + tp, syncErr = s.triggerPRStatusSync(ctx, w, taskID) + } + if syncErr != nil { + s.logger.Warn("per-repo PR sync failed", + zap.String("task_id", taskID), + zap.String("repository_id", w.RepositoryID), + zap.Int("pr_number", w.PRNumber), + zap.Error(syncErr)) + continue + } + if tp != nil { + results = append(results, tp) + } + } + return results, nil +} + func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { if s.client == nil { return nil, nil @@ -783,7 +902,7 @@ func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID zap.String("watch_id", watch.ID), zap.Int("pr_number", pr.Number), zap.Error(err)) return nil, fmt.Errorf("update PR watch: %w", err) } - if _, assocErr := s.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil { + if _, assocErr := s.AssociatePRWithTask(ctx, taskID, watch.RepositoryID, pr); assocErr != nil { s.logger.Error("failed to associate PR with task during sync", zap.String("task_id", taskID), zap.Int("pr_number", pr.Number), zap.Error(assocErr)) return nil, fmt.Errorf("associate PR: %w", assocErr) @@ -794,8 +913,23 @@ func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID } func (s *Service) triggerPRStatusSync(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) { - // Freshness check: skip GitHub API if recently synced - if tp, _ := s.store.GetTaskPR(ctx, taskID); tp != nil && tp.LastSyncedAt != nil { + // Freshness check: skip GitHub API if recently synced. Look up by the + // watch's own (task, repo) — the legacy GetTaskPR(ctx, taskID) returned + // "first match" which for multi-repo tasks would mistakenly hit the + // other repo's row and skip the sync that this watch actually needs. + loadTaskPR := func(c context.Context) (*TaskPR, error) { + tp, err := s.store.GetTaskPRByRepository(c, taskID, watch.RepositoryID) + if err != nil { + return nil, err + } + if tp != nil { + return tp, nil + } + // Fall back to the legacy untagged row for single-repo tasks that + // haven't been re-associated under the multi-repo schema yet. + return s.store.GetTaskPR(c, taskID) + } + if tp, _ := loadTaskPR(ctx); tp != nil && tp.LastSyncedAt != nil { if time.Since(*tp.LastSyncedAt) < prSyncFreshnessWindow { return tp, nil } @@ -811,12 +945,12 @@ func (s *Service) triggerPRStatusSync(ctx context.Context, watch *PRWatch, taskI return nil, checkErr } if status == nil { - return s.store.GetTaskPR(bgCtx, taskID) + return loadTaskPR(bgCtx) } if syncErr := s.SyncTaskPR(bgCtx, taskID, status); syncErr != nil { return nil, syncErr } - return s.store.GetTaskPR(bgCtx, taskID) + return loadTaskPR(bgCtx) }) if err != nil { return nil, err diff --git a/apps/backend/internal/github/service_review_cleanup_test.go b/apps/backend/internal/github/service_review_cleanup_test.go index eaa8227745..07d1094cfd 100644 --- a/apps/backend/internal/github/service_review_cleanup_test.go +++ b/apps/backend/internal/github/service_review_cleanup_test.go @@ -108,7 +108,7 @@ func TestAssociatePRWithTask_ReplacesStaleAssociation(t *testing.T) { RepoName: "repo", AuthorLogin: "alice", } - tp, err := svc.AssociatePRWithTask(ctx, "t1", newPR) + tp, err := svc.AssociatePRWithTask(ctx, "t1", "", newPR) if err != nil { t.Fatalf("AssociatePRWithTask: %v", err) } diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index c0ae8fea6f..3a852f0284 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "strings" "time" "github.com/google/uuid" @@ -29,11 +30,19 @@ func NewStore(writer, reader *sqlx.DB) (*Store, error) { } // createTablesSQL holds the DDL for all GitHub integration tables. +// +// Multi-repo: both `github_pr_watches` and `github_task_prs` carry a +// `repository_id` that names the per-task repository (when the task spans +// multiple repos). The uniqueness constraints include `repository_id` so +// each repo can have its own watch/PR for the same session/task. Existing +// installs migrated from the single-repo schema get the column dropped to +// `”` (empty) and the constraints rebuilt by `migratePRTablesForMultiRepo`. const createTablesSQL = ` CREATE TABLE IF NOT EXISTS github_pr_watches ( id TEXT PRIMARY KEY, - session_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL, repo TEXT NOT NULL, pr_number INTEGER NOT NULL, @@ -43,12 +52,14 @@ const createTablesSQL = ` last_check_status TEXT DEFAULT '', last_review_state TEXT DEFAULT '', created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL + updated_at DATETIME NOT NULL, + UNIQUE(session_id, repository_id) ); CREATE TABLE IF NOT EXISTS github_task_prs ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL, repo TEXT NOT NULL, pr_number INTEGER NOT NULL, @@ -71,7 +82,7 @@ const createTablesSQL = ` closed_at DATETIME, last_synced_at DATETIME, updated_at DATETIME NOT NULL, - UNIQUE(task_id, pr_number) + UNIQUE(task_id, repository_id, pr_number) ); CREATE TABLE IF NOT EXISTS github_review_watches ( @@ -149,9 +160,210 @@ func (s *Store) initSchema() error { // Idempotent migrations for existing databases. _, _ = s.db.Exec(`ALTER TABLE github_pr_watches ADD COLUMN last_review_state TEXT DEFAULT ''`) _, _ = s.db.Exec(`ALTER TABLE github_task_prs ADD COLUMN mergeable_state TEXT NOT NULL DEFAULT ''`) + // Phase 4 (multi-repo): per-repo PR association on github_task_prs. + _, _ = s.db.Exec(`ALTER TABLE github_task_prs ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''`) + _, _ = s.db.Exec(`ALTER TABLE github_pr_watches ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''`) + if err := s.migratePRTablesForMultiRepo(); err != nil { + return fmt.Errorf("migrate PR tables for multi-repo: %w", err) + } + if err := s.backfillTaskPRsRepositoryID(); err != nil { + return fmt.Errorf("backfill github_task_prs.repository_id: %w", err) + } return nil } +// backfillTaskPRsRepositoryID heals github_task_prs rows that pre-date the +// per-repo schema (i.e. have repository_id = ”). Two passes: +// +// 1. Dedup: for each (task_id, owner, repo, pr_number) tuple, if a legacy +// row (repository_id = ”) AND a newer per-repo row coexist, drop the +// legacy one. This happens when an old single-repo install upgraded and +// a subsequent sync inserted a new row under the resolved repository_id +// instead of updating the legacy row, leaving two entries for one PR +// (and so two badges on the kanban card). +// +// 2. Backfill: for any remaining empty-repo rows, look up the task's +// primary repository (from `task_repositories` ordered by position) and +// stamp its id. Skipped silently when the task has zero per-repo rows +// (e.g. quick-chat tasks) — the row keeps its empty repository_id. +// +// Idempotent: re-running on a healed db is a no-op. +func (s *Store) backfillTaskPRsRepositoryID() error { + if _, err := s.db.Exec(` + DELETE FROM github_task_prs + WHERE repository_id = '' + AND EXISTS ( + SELECT 1 FROM github_task_prs other + WHERE other.task_id = github_task_prs.task_id + AND other.owner = github_task_prs.owner + AND other.repo = github_task_prs.repo + AND other.pr_number = github_task_prs.pr_number + AND other.repository_id != '' + ) + `); err != nil { + return fmt.Errorf("dedup legacy task PR rows: %w", err) + } + // `task_repositories` lives in the task package's schema, not ours. + // Skip the backfill when it doesn't exist (e.g. github-store unit tests + // that init only this package's schema). The dedup pass above is the + // load-bearing fix for the "two PRs on a single-repo task" symptom; the + // backfill is a courtesy that converts orphan legacy rows in real + // deployments. + if !s.tableExists("task_repositories") { + return nil + } + _, err := s.db.Exec(` + UPDATE github_task_prs + SET repository_id = ( + SELECT tr.repository_id + FROM task_repositories tr + WHERE tr.task_id = github_task_prs.task_id + ORDER BY tr.position + LIMIT 1 + ) + WHERE repository_id = '' + AND EXISTS ( + SELECT 1 FROM task_repositories tr + WHERE tr.task_id = github_task_prs.task_id + ) + `) + if err != nil { + return fmt.Errorf("backfill task PR repository_id: %w", err) + } + return nil +} + +// tableExists returns true when the named table is present in sqlite_master. +// Used by the multi-repo backfill to skip cross-package healing in unit +// tests that don't bring up the task schema. +func (s *Store) tableExists(name string) bool { + var n int + err := s.db.QueryRow(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n) + return err == nil +} + +// migratePRTablesForMultiRepo rebuilds `github_pr_watches` and +// `github_task_prs` to drop the legacy single-repo unique constraints +// (`UNIQUE(session_id)` and `UNIQUE(task_id, pr_number)`) and replace them +// with the multi-repo variants. SQLite can't ALTER TABLE DROP CONSTRAINT, so +// each table is rebuilt via the recommended copy-and-rename pattern. The +// migration is idempotent: it inspects `sqlite_master.sql` for the legacy +// constraint string and only runs the rebuild when found. +func (s *Store) migratePRTablesForMultiRepo() error { + if err := s.rebuildIfHasLegacyConstraint( + "github_pr_watches", + "session_id TEXT NOT NULL UNIQUE", + `CREATE TABLE github_pr_watches_new ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + branch TEXT NOT NULL, + last_checked_at DATETIME, + last_comment_at DATETIME, + last_check_status TEXT DEFAULT '', + last_review_state TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE(session_id, repository_id) + )`, + `INSERT INTO github_pr_watches_new ( + id, session_id, task_id, repository_id, owner, repo, pr_number, branch, + last_checked_at, last_comment_at, last_check_status, last_review_state, + created_at, updated_at + ) SELECT + id, session_id, task_id, COALESCE(repository_id, ''), owner, repo, pr_number, branch, + last_checked_at, last_comment_at, last_check_status, last_review_state, + created_at, updated_at + FROM github_pr_watches`, + ); err != nil { + return err + } + return s.rebuildIfHasLegacyConstraint( + "github_task_prs", + "UNIQUE(task_id, pr_number)", + `CREATE TABLE github_task_prs_new ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + pr_url TEXT NOT NULL, + pr_title TEXT NOT NULL, + head_branch TEXT NOT NULL, + base_branch TEXT NOT NULL, + author_login TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'open', + review_state TEXT NOT NULL DEFAULT '', + checks_state TEXT NOT NULL DEFAULT '', + mergeable_state TEXT NOT NULL DEFAULT '', + review_count INTEGER DEFAULT 0, + pending_review_count INTEGER DEFAULT 0, + comment_count INTEGER DEFAULT 0, + additions INTEGER DEFAULT 0, + deletions INTEGER DEFAULT 0, + created_at DATETIME NOT NULL, + merged_at DATETIME, + closed_at DATETIME, + last_synced_at DATETIME, + updated_at DATETIME NOT NULL, + UNIQUE(task_id, repository_id, pr_number) + )`, + `INSERT INTO github_task_prs_new ( + id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, + head_branch, base_branch, author_login, state, review_state, checks_state, + mergeable_state, review_count, pending_review_count, comment_count, + additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at + ) SELECT + id, task_id, COALESCE(repository_id, ''), owner, repo, pr_number, pr_url, pr_title, + head_branch, base_branch, author_login, state, review_state, checks_state, + mergeable_state, review_count, pending_review_count, comment_count, + additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at + FROM github_task_prs`, + ) +} + +// rebuildIfHasLegacyConstraint checks the table's stored CREATE statement in +// `sqlite_master` for the literal `legacyConstraint` substring; if present, +// runs the table rebuild (create new, copy data, drop old, rename) inside a +// transaction. No-op when the legacy substring is absent — fresh installs +// already use the new schema and previously-migrated databases skip too. +func (s *Store) rebuildIfHasLegacyConstraint(table, legacyConstraint, createNew, copyData string) error { + var existingSQL string + row := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`, table) + if err := row.Scan(&existingSQL); err != nil { + // Table missing entirely shouldn't happen after createTablesSQL ran; + // treat as no-op to keep the migration robust under unexpected drift. + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + if !strings.Contains(existingSQL, legacyConstraint) { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for _, stmt := range []string{ + createNew, + copyData, + "DROP TABLE " + table, + "ALTER TABLE " + table + "_new RENAME TO " + table, + } { + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("rebuild %s: %w", table, err) + } + } + return tx.Commit() +} + // --- PR Watch operations --- // CreatePRWatch creates a new PR watch. @@ -163,23 +375,51 @@ func (s *Store) CreatePRWatch(ctx context.Context, w *PRWatch) error { w.CreatedAt = now w.UpdatedAt = now _, err := s.db.ExecContext(ctx, ` - INSERT INTO github_pr_watches (id, session_id, task_id, owner, repo, pr_number, branch, last_check_status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - w.ID, w.SessionID, w.TaskID, w.Owner, w.Repo, w.PRNumber, w.Branch, w.LastCheckStatus, w.CreatedAt, w.UpdatedAt) + INSERT INTO github_pr_watches (id, session_id, task_id, repository_id, owner, repo, pr_number, branch, last_check_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + w.ID, w.SessionID, w.TaskID, w.RepositoryID, w.Owner, w.Repo, w.PRNumber, w.Branch, w.LastCheckStatus, w.CreatedAt, w.UpdatedAt) return err } -// GetPRWatchBySession returns the PR watch for a session. +// GetPRWatchBySession returns the first PR watch for a session. For +// multi-repo sessions the result is non-deterministic across repos — use +// GetPRWatchBySessionAndRepo or ListPRWatchesBySession instead. func (s *Store) GetPRWatchBySession(ctx context.Context, sessionID string) (*PRWatch, error) { var w PRWatch - err := s.ro.GetContext(ctx, &w, `SELECT * FROM github_pr_watches WHERE session_id = ?`, sessionID) + err := s.ro.GetContext(ctx, &w, + `SELECT * FROM github_pr_watches WHERE session_id = ? LIMIT 1`, sessionID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return &w, err } -// GetPRWatchByTask returns the PR watch for a task (first match). +// GetPRWatchBySessionAndRepo returns the PR watch for a (session, repository) +// pair, or nil. Used by per-repo branch-switch / commit handlers so each +// repo's watch is reset independently. +func (s *Store) GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*PRWatch, error) { + var w PRWatch + err := s.ro.GetContext(ctx, &w, + `SELECT * FROM github_pr_watches WHERE session_id = ? AND repository_id = ? LIMIT 1`, + sessionID, repositoryID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &w, err +} + +// ListPRWatchesBySession returns every PR watch for a session (one per repo +// in multi-repo workspaces). Empty slice when no watches exist. +func (s *Store) ListPRWatchesBySession(ctx context.Context, sessionID string) ([]*PRWatch, error) { + var watches []*PRWatch + err := s.ro.SelectContext(ctx, &watches, + `SELECT * FROM github_pr_watches WHERE session_id = ? ORDER BY created_at ASC`, sessionID) + return watches, err +} + +// GetPRWatchByTask returns the first PR watch for a task. For multi-repo +// tasks the result is non-deterministic across repos — use +// ListPRWatchesByTask when every repo's watch is needed. func (s *Store) GetPRWatchByTask(ctx context.Context, taskID string) (*PRWatch, error) { var w PRWatch err := s.ro.GetContext(ctx, &w, `SELECT * FROM github_pr_watches WHERE task_id = ? ORDER BY updated_at DESC LIMIT 1`, taskID) @@ -189,6 +429,15 @@ func (s *Store) GetPRWatchByTask(ctx context.Context, taskID string) (*PRWatch, return &w, err } +// ListPRWatchesByTask returns every PR watch for a task (one per repo in +// multi-repo workspaces). +func (s *Store) ListPRWatchesByTask(ctx context.Context, taskID string) ([]*PRWatch, error) { + var watches []*PRWatch + err := s.ro.SelectContext(ctx, &watches, + `SELECT * FROM github_pr_watches WHERE task_id = ? ORDER BY created_at ASC`, taskID) + return watches, err +} + // ListActivePRWatches returns all active PR watches whose task is not archived. // Watches for archived tasks (and orphaned watches whose task row was hard-deleted) // are excluded so the poller stops making GitHub API calls for them. An INNER JOIN @@ -263,7 +512,9 @@ func (s *Store) UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch s // --- TaskPR operations --- -// CreateTaskPR associates a PR with a task. +// CreateTaskPR associates a PR with a task. RepositoryID may be empty for +// single-repo tasks; multi-repo task launches set it so each repo's PR is +// distinguishable. func (s *Store) CreateTaskPR(ctx context.Context, tp *TaskPR) error { if tp.ID == "" { tp.ID = uuid.New().String() @@ -271,17 +522,18 @@ func (s *Store) CreateTaskPR(ctx context.Context, tp *TaskPR) error { now := time.Now().UTC() tp.UpdatedAt = now _, err := s.db.ExecContext(ctx, ` - INSERT INTO github_task_prs (id, task_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, + INSERT INTO github_task_prs (id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, state, review_state, checks_state, mergeable_state, review_count, pending_review_count, comment_count, additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tp.ID, tp.TaskID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tp.ID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.ReviewCount, tp.PendingReviewCount, tp.CommentCount, tp.Additions, tp.Deletions, tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt) return err } -// GetTaskPR returns the PR association for a task. +// GetTaskPR returns the first PR association for a task. For multi-repo tasks +// the result is non-deterministic across repos — use ListTaskPRsByTask instead. func (s *Store) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) { var tp TaskPR err := s.ro.GetContext(ctx, &tp, `SELECT * FROM github_task_prs WHERE task_id = ? LIMIT 1`, taskID) @@ -291,12 +543,45 @@ func (s *Store) GetTaskPR(ctx context.Context, taskID string) (*TaskPR, error) { return &tp, err } -// ListTaskPRsByTaskIDs returns PR associations for multiple tasks. -func (s *Store) ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]*TaskPR, error) { - if len(taskIDs) == 0 { - return make(map[string]*TaskPR), nil +// GetTaskPRByRepository returns the PR association for a (task, repository) +// pair, or nil if none. Use this for multi-repo tasks. +func (s *Store) GetTaskPRByRepository(ctx context.Context, taskID, repositoryID string) (*TaskPR, error) { + var tp TaskPR + err := s.ro.GetContext(ctx, &tp, + `SELECT * FROM github_task_prs WHERE task_id = ? AND repository_id = ? LIMIT 1`, + taskID, repositoryID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil } - query, args, err := sqlx.In(`SELECT * FROM github_task_prs WHERE task_id IN (?)`, taskIDs) + return &tp, err +} + +// ListTaskPRsByTask returns every PR association for a task (one per repo +// when multi-repo). Empty slice when no PRs exist. +func (s *Store) ListTaskPRsByTask(ctx context.Context, taskID string) ([]*TaskPR, error) { + var prs []TaskPR + if err := s.ro.SelectContext(ctx, &prs, + `SELECT * FROM github_task_prs WHERE task_id = ? ORDER BY created_at ASC`, taskID); err != nil { + return nil, err + } + out := make([]*TaskPR, 0, len(prs)) + for i := range prs { + out = append(out, &prs[i]) + } + return out, nil +} + +// ListTaskPRsByTaskIDs returns PR associations for multiple tasks. Each task +// may have multiple PRs (one per repository for multi-repo tasks); rows are +// returned grouped by task_id, ordered by created_at ascending within a group. +func (s *Store) ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*TaskPR, error) { + if len(taskIDs) == 0 { + return make(map[string][]*TaskPR), nil + } + query, args, err := sqlx.In( + `SELECT * FROM github_task_prs WHERE task_id IN (?) ORDER BY created_at ASC`, + taskIDs, + ) if err != nil { return nil, err } @@ -305,34 +590,38 @@ func (s *Store) ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map if err := s.ro.SelectContext(ctx, &prs, query, args...); err != nil { return nil, err } - result := make(map[string]*TaskPR, len(prs)) - for i := range prs { - result[prs[i].TaskID] = &prs[i] - } - return result, nil + return groupTaskPRsByTask(prs), nil } // ListTaskPRsByWorkspaceID returns all PR associations for tasks in a workspace. -func (s *Store) ListTaskPRsByWorkspaceID(ctx context.Context, workspaceID string) (map[string]*TaskPR, error) { +// Each task may have multiple PRs (one per repository for multi-repo tasks); +// rows are returned grouped by task_id, ordered by created_at ascending. +func (s *Store) ListTaskPRsByWorkspaceID(ctx context.Context, workspaceID string) (map[string][]*TaskPR, error) { var prs []TaskPR if err := s.ro.SelectContext(ctx, &prs, `SELECT gtp.* FROM github_task_prs gtp INNER JOIN tasks t ON gtp.task_id = t.id - WHERE t.workspace_id = ?`, workspaceID); err != nil { + WHERE t.workspace_id = ? + ORDER BY gtp.created_at ASC`, workspaceID); err != nil { return nil, err } - result := make(map[string]*TaskPR, len(prs)) + return groupTaskPRsByTask(prs), nil +} + +func groupTaskPRsByTask(prs []TaskPR) map[string][]*TaskPR { + result := make(map[string][]*TaskPR) for i := range prs { - result[prs[i].TaskID] = &prs[i] + taskID := prs[i].TaskID + result[taskID] = append(result[taskID], &prs[i]) } - return result, nil + return result } // ReplaceTaskPR atomically replaces the task→PR association for a task: any -// existing rows for task_id are deleted and the new row is inserted inside a -// single transaction, matching the effective 1:1 task→PR mapping used by -// reads. Use this when a task's active PR changes (e.g. the first PR was -// closed and a follow-up was opened). +// existing rows are deleted and the new row is inserted inside a single +// transaction. The delete is scoped to (task_id, repository_id) so multi-repo +// tasks only replace the row for the affected repo; for single-repo tasks +// (RepositoryID == "") the legacy semantics are preserved (delete all). func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR) error { if tp.ID == "" { tp.ID = uuid.New().String() @@ -346,15 +635,31 @@ func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR) error { } defer func() { _ = tx.Rollback() }() - if _, err := tx.ExecContext(ctx, `DELETE FROM github_task_prs WHERE task_id = ?`, tp.TaskID); err != nil { - return err + // Multi-repo: only delete the row for the same (task, repo) pair, leaving + // other repos' rows alone. Single-repo callers (RepositoryID == "") + // only delete legacy untagged rows so a multi-repo task's per-repo rows + // don't get wiped if a single-repo callsite slips in. The poller and + // service.AssociatePR* paths now always pass RepositoryID explicitly; + // any future caller that forgets does the safer thing (preserve tagged + // rows) instead of the old "nuke everything" behavior. + if tp.RepositoryID != "" { + if _, err := tx.ExecContext(ctx, + `DELETE FROM github_task_prs WHERE task_id = ? AND repository_id = ?`, + tp.TaskID, tp.RepositoryID); err != nil { + return err + } + } else { + if _, err := tx.ExecContext(ctx, + `DELETE FROM github_task_prs WHERE task_id = ? AND repository_id = ''`, tp.TaskID); err != nil { + return err + } } if _, err := tx.ExecContext(ctx, ` - INSERT INTO github_task_prs (id, task_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, + INSERT INTO github_task_prs (id, task_id, repository_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, state, review_state, checks_state, mergeable_state, review_count, pending_review_count, comment_count, additions, deletions, created_at, merged_at, closed_at, last_synced_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tp.ID, tp.TaskID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tp.ID, tp.TaskID, tp.RepositoryID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, tp.State, tp.ReviewState, tp.ChecksState, tp.MergeableState, tp.ReviewCount, tp.PendingReviewCount, tp.CommentCount, tp.Additions, tp.Deletions, tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt); err != nil { return err diff --git a/apps/backend/internal/github/store_multi_repo_test.go b/apps/backend/internal/github/store_multi_repo_test.go new file mode 100644 index 0000000000..90fd998af6 --- /dev/null +++ b/apps/backend/internal/github/store_multi_repo_test.go @@ -0,0 +1,225 @@ +package github + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "github.db") + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + if _, err := sqlxDB.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, archived_at DATETIME)`); err != nil { + t.Fatalf("create tasks table: %v", err) + } + store, err := NewStore(sqlxDB, sqlxDB) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func TestTaskPR_PerRepoStorage(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + now := time.Now().UTC() + prFront := &TaskPR{ + TaskID: "task-1", RepositoryID: "repo-front", + Owner: "kdlbs", Repo: "kandev", PRNumber: 100, + PRURL: "https://github.com/kdlbs/kandev/pull/100", + PRTitle: "frontend changes", HeadBranch: "feat/x", BaseBranch: "main", + State: "open", CreatedAt: now, + } + prBack := &TaskPR{ + TaskID: "task-1", RepositoryID: "repo-back", + Owner: "kdlbs", Repo: "kandev-backend", PRNumber: 200, + PRURL: "https://github.com/kdlbs/kandev-backend/pull/200", + PRTitle: "backend changes", HeadBranch: "feat/x", BaseBranch: "main", + State: "open", CreatedAt: now, + } + + if err := store.CreateTaskPR(ctx, prFront); err != nil { + t.Fatalf("create front PR: %v", err) + } + if err := store.CreateTaskPR(ctx, prBack); err != nil { + t.Fatalf("create back PR: %v", err) + } + + gotFront, err := store.GetTaskPRByRepository(ctx, "task-1", "repo-front") + if err != nil { + t.Fatalf("get front: %v", err) + } + if gotFront == nil || gotFront.PRNumber != 100 { + t.Errorf("expected front PR #100, got %+v", gotFront) + } + + gotBack, err := store.GetTaskPRByRepository(ctx, "task-1", "repo-back") + if err != nil { + t.Fatalf("get back: %v", err) + } + if gotBack == nil || gotBack.PRNumber != 200 { + t.Errorf("expected back PR #200, got %+v", gotBack) + } + + all, err := store.ListTaskPRsByTask(ctx, "task-1") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(all) != 2 { + t.Errorf("expected 2 PRs for task, got %d", len(all)) + } +} + +func TestTaskPR_ReplaceTaskPR_ScopedByRepository(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + now := time.Now().UTC() + if err := store.CreateTaskPR(ctx, &TaskPR{ + TaskID: "task-2", RepositoryID: "repo-a", + Owner: "o", Repo: "r", PRNumber: 1, CreatedAt: now, + }); err != nil { + t.Fatalf("create A: %v", err) + } + if err := store.CreateTaskPR(ctx, &TaskPR{ + TaskID: "task-2", RepositoryID: "repo-b", + Owner: "o", Repo: "r2", PRNumber: 2, CreatedAt: now, + }); err != nil { + t.Fatalf("create B: %v", err) + } + + // Replace only repo-a's PR — repo-b must survive. + if err := store.ReplaceTaskPR(ctx, &TaskPR{ + TaskID: "task-2", RepositoryID: "repo-a", + Owner: "o", Repo: "r", PRNumber: 99, CreatedAt: now, + }); err != nil { + t.Fatalf("replace A: %v", err) + } + + all, _ := store.ListTaskPRsByTask(ctx, "task-2") + if len(all) != 2 { + t.Fatalf("expected 2 PRs after scoped replace, got %d", len(all)) + } + bySpec := map[string]int{} + for _, p := range all { + bySpec[p.RepositoryID] = p.PRNumber + } + if bySpec["repo-a"] != 99 { + t.Errorf("expected repo-a updated to 99, got %d", bySpec["repo-a"]) + } + if bySpec["repo-b"] != 2 { + t.Errorf("expected repo-b unchanged at 2, got %d", bySpec["repo-b"]) + } +} + +// Regression: a task that pre-dated the multi-repo schema kept a row with +// repository_id=”, then a later sync inserted a NEW row under the resolved +// repository_id — leaving two rows for the same PR and "+2" badges on the +// kanban card. backfillTaskPRsRepositoryID must dedup the legacy row. +func TestBackfillTaskPRsRepositoryID_DedupsLegacyEmptyRow(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + // Insert a legacy row (repository_id = '') and a per-repo row for the + // same (task, owner, repo, pr_number). The unique index is + // (task_id, repository_id, pr_number), so both rows can coexist. + if err := store.CreateTaskPR(ctx, &TaskPR{ + ID: "legacy", TaskID: "task-x", RepositoryID: "", + Owner: "kdlbs", Repo: "kandev", PRNumber: 767, CreatedAt: now, + }); err != nil { + t.Fatalf("create legacy: %v", err) + } + if err := store.CreateTaskPR(ctx, &TaskPR{ + ID: "scoped", TaskID: "task-x", RepositoryID: "repo-1", + Owner: "kdlbs", Repo: "kandev", PRNumber: 767, CreatedAt: now, + }); err != nil { + t.Fatalf("create scoped: %v", err) + } + + if err := store.backfillTaskPRsRepositoryID(); err != nil { + t.Fatalf("backfill: %v", err) + } + + all, _ := store.ListTaskPRsByTask(ctx, "task-x") + if len(all) != 1 { + t.Fatalf("expected 1 PR after dedup, got %d", len(all)) + } + if all[0].RepositoryID != "repo-1" { + t.Errorf("expected scoped row to win, got repository_id=%q", all[0].RepositoryID) + } + + // Idempotent: second pass is a no-op. + if err := store.backfillTaskPRsRepositoryID(); err != nil { + t.Fatalf("second backfill: %v", err) + } + all2, _ := store.ListTaskPRsByTask(ctx, "task-x") + if len(all2) != 1 { + t.Errorf("idempotent expected 1 PR, got %d", len(all2)) + } +} + +// When task_repositories does not exist (e.g. github-store unit tests that +// init only this package's schema), backfill must skip the cross-package +// UPDATE rather than erroring out. +func TestBackfillTaskPRsRepositoryID_SkipsWhenTaskRepositoriesAbsent(t *testing.T) { + store := newTestStore(t) + if err := store.backfillTaskPRsRepositoryID(); err != nil { + t.Fatalf("backfill on store without task_repositories: %v", err) + } +} + +// Covers the UPDATE path: a legacy row with repository_id=” and NO per-repo +// counterpart should get its repository_id stamped from task_repositories, +// not deleted. This is the most common case in real upgrades — a task that +// has only ever had one PR (so no duplicate to dedup) but pre-dates the +// per-repo schema. +func TestBackfillTaskPRsRepositoryID_BackfillsOrphanRow(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if _, err := store.db.Exec(`CREATE TABLE task_repositories ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, + repository_id TEXT NOT NULL, position INTEGER DEFAULT 0 + )`); err != nil { + t.Fatalf("create task_repositories: %v", err) + } + if _, err := store.db.Exec( + `INSERT INTO task_repositories VALUES ('r1','task-y','repo-abc',0)`, + ); err != nil { + t.Fatalf("seed task_repositories: %v", err) + } + + // Orphan legacy row: no per-repo counterpart, so the dedup DELETE leaves + // it alone and the UPDATE has to do the healing. + if err := store.CreateTaskPR(ctx, &TaskPR{ + ID: "legacy2", TaskID: "task-y", RepositoryID: "", + Owner: "o", Repo: "r", PRNumber: 1, CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("create legacy: %v", err) + } + + if err := store.backfillTaskPRsRepositoryID(); err != nil { + t.Fatalf("backfill: %v", err) + } + + all, _ := store.ListTaskPRsByTask(ctx, "task-y") + if len(all) != 1 { + t.Fatalf("expected 1 row, got %d", len(all)) + } + if all[0].RepositoryID != "repo-abc" { + t.Errorf("expected backfilled repository_id='repo-abc', got %q", all[0].RepositoryID) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go index dc091d2d9d..d45c1d9087 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git.go +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "strings" "sync" "time" @@ -206,29 +207,91 @@ func (s *Service) persistGitStatusSnapshot(ctx context.Context, data watcher.Git } // trackPushAndAssociatePR detects git pushes by tracking the "ahead" count. -// When ahead transitions from >0 to 0 with a remote branch set, a push occurred. +// Two cases trigger detection: +// +// 1. Transition: ahead went from >0 to 0 with a remote branch set — the +// normal in-session push, observed across two status events. +// 2. First-observation sync: the very first status event for this +// (session, repo) already shows ahead=0 with a remote branch. This means +// a push happened before agentctl's poller saw the ahead>0 phase (the +// poll cadence missed it, or the session resumed after a restart). For a +// fresh task branch, RemoteBranch is only populated after `git push -u`, +// so seeing it pre-synced is itself a push signal. +// +// Without (2), multi-repo tasks routinely lose PR associations for any repo +// whose first poll happens to land after the push completes — the +// transition never gets observed and the watch never gets created. +// +// Multi-repo: keyed per (session, repository_name) so each repo's +// transitions are tracked independently. Without this, agentctl's per-repo +// status events race-overwrote each other's ahead counts and only one +// repo's push got detected. func (s *Service) trackPushAndAssociatePR(ctx context.Context, data watcher.GitEventData) { - prevAheadVal, loaded := s.pushTracker.Swap(data.SessionID, data.Status.Ahead) - if !loaded { - return // first status update for this session, skip - } - prevAhead, ok := prevAheadVal.(int) - if !ok || prevAhead <= 0 { + key := pushTrackerKey(data.SessionID, data.Status.RepositoryName) + prevAheadVal, loaded := s.pushTracker.Swap(key, data.Status.Ahead) + prevAhead, _ := prevAheadVal.(int) + if !shouldFirePushDetection(loaded, prevAhead, data.Status) { return } - // Push detected: ahead went from >0 to 0 - if data.Status.Ahead == 0 && data.Status.RemoteBranch != "" { - s.logger.Info("git push detected, starting PR association", - zap.String("session_id", data.SessionID), - zap.String("task_id", data.TaskID), - zap.String("branch", data.Status.Branch)) - go s.detectPushAndAssociatePR( - context.Background(), - data.SessionID, - data.TaskID, - data.Status.Branch, - ) + s.logger.Info("git push detected, starting PR association", + zap.String("session_id", data.SessionID), + zap.String("task_id", data.TaskID), + zap.String("repository_name", data.Status.RepositoryName), + zap.String("branch", data.Status.Branch), + zap.Bool("first_observation", !loaded)) + go s.detectPushAndAssociatePR( + context.Background(), + data.SessionID, + data.TaskID, + data.Status.RepositoryName, + data.Status.Branch, + ) +} + +// shouldFirePushDetection decides whether to kick off PR association for one +// status event. It fires in two cases (see trackPushAndAssociatePR doc): +// +// - Transition: the previous observation had ahead>0 and this one has ahead=0 +// with a remote branch set. +// - First observation: no previous entry, this one has ahead=0 with a +// remote branch set — meaning a push happened before agentctl's poller +// observed the ahead>0 phase. +// +// Pulled out as a pure function so the decision logic can be tested without +// spawning the goroutine that calls the GitHub API. +func shouldFirePushDetection(loaded bool, prevAhead int, status *lifecycle.GitStatusData) bool { + if status == nil { + return false } + if status.RemoteBranch == "" || status.Ahead != 0 { + return false + } + if !loaded { + return true + } + return prevAhead > 0 +} + +// pushTrackerKey builds the per-(session, repo) key used by pushTracker. +// Empty repository_name (single-repo / repo-less sessions) keeps the legacy +// single-key behaviour. +func pushTrackerKey(sessionID, repositoryName string) string { + return sessionID + "|" + repositoryName +} + +// pushTrackerForget drops every pushTracker entry belonging to a session. The +// tracker is keyed (session|repo); a single session can have N entries (one +// per repo in a multi-repo task). Called when the session is deleted so its +// tracker rows don't linger for the lifetime of the process. +func (s *Service) pushTrackerForget(sessionID string) { + prefix := sessionID + "|" + s.pushTracker.Range(func(k, _ any) bool { + key, ok := k.(string) + if ok && strings.HasPrefix(key, prefix) { + s.pushTracker.Delete(key) + } + return true + }) } // syncPRWatchBranch updates the PR watch branch if the live git branch @@ -377,15 +440,16 @@ func (s *Service) handleGitCommitCreated(ctx context.Context, data watcher.GitEv TaskID: data.TaskID, Timestamp: time.Now().Format("2006-01-02T15:04:05.000000000Z07:00"), Commit: &lifecycle.GitCommitData{ - CommitSHA: data.Commit.CommitSHA, - ParentSHA: data.Commit.ParentSHA, - Message: data.Commit.Message, - AuthorName: data.Commit.AuthorName, - AuthorEmail: data.Commit.AuthorEmail, - FilesChanged: data.Commit.FilesChanged, - Insertions: data.Commit.Insertions, - Deletions: data.Commit.Deletions, - CommittedAt: data.Commit.CommittedAt, + CommitSHA: data.Commit.CommitSHA, + ParentSHA: data.Commit.ParentSHA, + Message: data.Commit.Message, + AuthorName: data.Commit.AuthorName, + AuthorEmail: data.Commit.AuthorEmail, + FilesChanged: data.Commit.FilesChanged, + Insertions: data.Commit.Insertions, + Deletions: data.Commit.Deletions, + CommittedAt: data.Commit.CommittedAt, + RepositoryName: data.Commit.RepositoryName, }, }) _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) @@ -415,8 +479,9 @@ func (s *Service) handleGitCommitsReset(ctx context.Context, data watcher.GitEve TaskID: data.TaskID, Timestamp: time.Now().Format("2006-01-02T15:04:05.000000000Z07:00"), Reset: &lifecycle.GitResetData{ - PreviousHead: data.Reset.PreviousHead, - CurrentHead: data.Reset.CurrentHead, + PreviousHead: data.Reset.PreviousHead, + CurrentHead: data.Reset.CurrentHead, + RepositoryName: data.Reset.RepositoryName, }, }) _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) @@ -485,6 +550,7 @@ func (s *Service) handleBranchSwitched(ctx context.Context, data watcher.GitEven CurrentBranch: data.BranchSwitch.CurrentBranch, CurrentHead: data.BranchSwitch.CurrentHead, BaseCommit: data.BranchSwitch.BaseCommit, + RepositoryName: data.BranchSwitch.RepositoryName, }, }) _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) diff --git a/apps/backend/internal/orchestrator/event_handlers_git_test.go b/apps/backend/internal/orchestrator/event_handlers_git_test.go index a4863a8830..9c773bba20 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_git_test.go @@ -120,3 +120,109 @@ func TestHandleBranchSwitched_ResetsPRWatch(t *testing.T) { t.Errorf("reset watch branch = %q, want feature/b", ghSvc.resetWatchBranch) } } + +// shouldFirePushDetection is the trigger predicate for trackPushAndAssociatePR. +// The first-observation case is the regression that was breaking multi-repo +// PR detection in production: any repo whose first agentctl status poll landed +// after the push had completed would silently never get its PR associated, +// because the >0→0 transition was never observed. See task +// 4fdff41b-095a-4158-a311-4a1a23abe064 for the original failure mode. +func TestShouldFirePushDetection(t *testing.T) { + tests := []struct { + name string + loaded bool + prev int + status *lifecycle.GitStatusData + wantOn bool + wantWhy string + }{ + { + name: "first observation, ahead=0 with remote branch fires (push happened pre-poll)", + loaded: false, + prev: 0, + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: "origin/feature/x"}, + wantOn: true, + wantWhy: "first-observation sync — pre-existing remote branch in synced state means a push completed before we started watching", + }, + { + name: "first observation, no remote branch does not fire", + loaded: false, + prev: 0, + // Branch never been pushed — RemoteBranch is empty. + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: ""}, + wantOn: false, + }, + { + name: "first observation, ahead>0 does not fire (waiting for transition)", + loaded: false, + prev: 0, + status: &lifecycle.GitStatusData{Ahead: 3, RemoteBranch: "origin/feature/x"}, + wantOn: false, + }, + { + name: "transition >0 to 0 with remote fires (legacy in-session push)", + loaded: true, + prev: 2, + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: "origin/feature/x"}, + wantOn: true, + }, + { + name: "stays at 0 after first fire does not refire", + loaded: true, + prev: 0, + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: "origin/feature/x"}, + wantOn: false, + }, + { + name: "transition with no remote does not fire (local-only commit was undone)", + loaded: true, + prev: 1, + status: &lifecycle.GitStatusData{Ahead: 0, RemoteBranch: ""}, + wantOn: false, + }, + { + name: "nil status does not fire", + loaded: true, + prev: 1, + status: nil, + wantOn: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldFirePushDetection(tt.loaded, tt.prev, tt.status) + if got != tt.wantOn { + t.Errorf("shouldFirePushDetection = %v, want %v", got, tt.wantOn) + } + }) + } +} + +// pushTrackerForget must drop every entry for the given session, regardless of +// how many repos are tracked under it. Multi-repo tasks accumulate one entry +// per repo (key = "session|repo"); leaving them behind on session delete +// would slowly leak memory across the lifetime of the process. +func TestPushTrackerForget(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + + // Three sessions, multiple repos for s1. + svc.pushTracker.Store(pushTrackerKey("s1", "frontend"), 0) + svc.pushTracker.Store(pushTrackerKey("s1", "backend"), 0) + svc.pushTracker.Store(pushTrackerKey("s1", ""), 0) // legacy single-repo key + svc.pushTracker.Store(pushTrackerKey("s2", "frontend"), 0) + // "s10" is a guard against accidental prefix-match: "s1|" must not match "s10|". + svc.pushTracker.Store(pushTrackerKey("s10", "frontend"), 0) + + svc.pushTrackerForget("s1") + + for _, k := range []string{"s1|frontend", "s1|backend", "s1|"} { + if _, ok := svc.pushTracker.Load(k); ok { + t.Errorf("expected %q to be removed", k) + } + } + for _, k := range []string{"s2|frontend", "s10|frontend"} { + if _, ok := svc.pushTracker.Load(k); !ok { + t.Errorf("expected %q to survive (different session)", k) + } + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index cab6fb75b3..0e47764a16 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -24,15 +24,20 @@ const ( ) // GitHubService is the interface the orchestrator uses for GitHub operations. +// All PR-write methods carry repository_id so multi-repo tasks (where the +// same task spans multiple repos and so multiple PRs) don't collapse to one +// row by colliding on the legacy UNIQUE(session_id) / UNIQUE(task_id, pr_number) +// constraints. Empty repository_id preserves single-repo legacy behavior. type GitHubService interface { Client() github.Client - CreatePRWatch(ctx context.Context, sessionID, taskID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error) - EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, repo, branch string) (*github.PRWatch, error) + CreatePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error) + EnsurePRWatch(ctx context.Context, sessionID, taskID, repositoryID, owner, repo, branch string) (*github.PRWatch, error) GetPRWatchBySession(ctx context.Context, sessionID string) (*github.PRWatch, error) + GetPRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*github.PRWatch, error) UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error ResetPRWatch(ctx context.Context, id, branch string) error - AssociatePRWithTask(ctx context.Context, taskID string, pr *github.PR) (*github.TaskPR, error) + AssociatePRWithTask(ctx context.Context, taskID, repositoryID string, pr *github.PR) (*github.TaskPR, error) GetTaskPR(ctx context.Context, taskID string) (*github.TaskPR, error) ListActivePRWatches(ctx context.Context) ([]*github.PRWatch, error) ReserveReviewPRTask(ctx context.Context, watchID, repoOwner, repoName string, prNumber int, prURL string) (bool, error) @@ -254,7 +259,12 @@ func (s *Service) attachTaskToReservation(ctx context.Context, evt *github.NewRe zap.Int("pr_number", pr.Number), zap.Error(err)) } - if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, pr); err != nil { + // Review tasks are single-repo (the PR's own repo). Resolve to that + // task_repository's repository_id so the resulting TaskPR row is scoped + // per-repo even though there's only one. Empty falls back to legacy + // "delete all" semantics which is fine for the 1-repo case. + repositoryID := s.resolvePrimaryTaskRepositoryID(ctx, taskID) + if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, repositoryID, pr); err != nil { s.logger.Error("failed to associate PR with review task", zap.String("task_id", taskID), zap.Int("pr_number", pr.Number), @@ -362,13 +372,19 @@ func (s *Service) resolveReviewRepository(ctx context.Context, workspaceID strin // detectPushAndAssociatePR checks if a push happened and looks for a PR on // that branch. If no PR is found immediately, retries after a delay to handle // the case where the user creates the PR on GitHub shortly after pushing. -// The pushTracker entry for this session is always removed when the function returns -// to prevent unbounded growth of the tracker map. +// +// The pushTracker entry is intentionally NOT deleted on return: leaving the +// stored ahead=0 in place causes subsequent status events to fall into the +// `prevAhead <= 0` skip in trackPushAndAssociatePR, which prevents repeated +// firing on every status event for a synced branch. The tracker is cleaned +// up at session-deletion time via pushTrackerForget. +// +// Multi-repo: repositoryName scopes the lookup so each repo's push is detected +// and associated independently. Empty repositoryName falls back to the +// session's primary repo (legacy single-repo path). func (s *Service) detectPushAndAssociatePR( - ctx context.Context, sessionID, taskID, branch string, + ctx context.Context, sessionID, taskID, repositoryName, branch string, ) { - defer s.pushTracker.Delete(sessionID) - if s.githubService == nil { return } @@ -377,12 +393,17 @@ func (s *Service) detectPushAndAssociatePR( return } - // Check if we already have a watch for this session. + owner, repoName, repositoryID := s.resolvePushRepo(ctx, sessionID, taskID, repositoryName) + if owner == "" || repoName == "" { + return + } + + // Check if we already have a watch for this (session, repo). // If the watch already has a PR number, the PR was found — nothing to do. // If the watch has pr_number=0, it's still searching — do an immediate // search (faster than waiting for the 1-minute poller) and update the // watch branch if the agent pushed from a different branch. - existing, err := s.githubService.GetPRWatchBySession(ctx, sessionID) + existing, err := s.githubService.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID) if err == nil && existing != nil { if existing.PRNumber > 0 { return // PR already found and being monitored @@ -391,11 +412,6 @@ func (s *Service) detectPushAndAssociatePR( return } - owner, repoName := s.resolveSessionRepo(ctx, sessionID) - if owner == "" || repoName == "" { - return - } - // Try to find a PR immediately, then retry after delays delays := []time.Duration{0, 30 * time.Second, 60 * time.Second} for _, delay := range delays { @@ -406,7 +422,7 @@ func (s *Service) detectPushAndAssociatePR( case <-time.After(delay): } // Re-check if a watch was created in the meantime (e.g. by CreatePR callback) - if ex, err := s.githubService.GetPRWatchBySession(ctx, sessionID); err == nil && ex != nil { + if ex, err := s.githubService.GetPRWatchBySessionAndRepo(ctx, sessionID, repositoryID); err == nil && ex != nil { return } } @@ -415,23 +431,66 @@ func (s *Service) detectPushAndAssociatePR( s.logger.Debug("no PR found for branch (will retry)", zap.String("branch", branch), zap.String("session_id", sessionID), + zap.String("repository_name", repositoryName), zap.Duration("delay", delay)) continue } s.logger.Info("PR found after push, associating with task", zap.String("session_id", sessionID), zap.String("task_id", taskID), + zap.String("repository_name", repositoryName), zap.Int("pr_number", foundPR.Number), zap.String("branch", branch)) - s.associatePRFromPush(ctx, sessionID, taskID, owner, repoName, branch, foundPR) + s.associatePRFromPushScoped(ctx, sessionID, taskID, owner, repoName, repositoryID, branch, foundPR) return } s.logger.Warn("exhausted all retries, no PR found after push", zap.String("session_id", sessionID), zap.String("task_id", taskID), + zap.String("repository_name", repositoryName), zap.String("branch", branch)) } +// resolvePushRepo returns (owner, name, repository_id) for the per-repo push +// detection. Multi-repo path: looks up the task_repository whose repository's +// Name matches `repositoryName`. Empty name falls back to the session's +// primary repo (legacy single-repo behaviour). +func (s *Service) resolvePushRepo( + ctx context.Context, sessionID, taskID, repositoryName string, +) (owner, repo, repositoryID string) { + if repositoryName == "" { + o, r := s.resolveSessionRepo(ctx, sessionID) + return o, r, s.resolvePrimaryTaskRepositoryID(ctx, taskID) + } + store, ok := s.repo.(repoStore) + if !ok { + return "", "", "" + } + links, err := store.ListTaskRepositories(ctx, taskID) + if err != nil { + return "", "", "" + } + for _, link := range links { + repoObj, err := store.GetRepository(ctx, link.RepositoryID) + if err != nil || repoObj == nil { + continue + } + if repoObj.Name != repositoryName { + continue + } + if repoObj.ProviderOwner == "" && repoObj.LocalPath != "" { + if p, o, n := service.ResolveGitRemoteProvider(repoObj.LocalPath); o != "" { + repoObj.Provider = p + repoObj.ProviderOwner = o + repoObj.ProviderName = n + go s.backfillRepoProvider(store, repoObj) + } + } + return repoObj.ProviderOwner, repoObj.ProviderName, link.RepositoryID + } + return "", "", "" +} + // searchPRForExistingWatch handles the case where a PR watch exists with pr_number=0 // (still searching). It updates the watch branch if the agent pushed from a different // branch, then does a single immediate search so we don't wait for the 1-minute poller. @@ -461,7 +520,11 @@ func (s *Service) searchPRForExistingWatch( zap.Int("pr_number", foundPR.Number), zap.Error(err)) } - if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, foundPR); err != nil { + // Use the watch's own repository_id so the association lands on the + // correct per-repo TaskPR row (matters once multi-repo watches exist; + // for legacy single-repo watches this is empty and matches the old + // "delete all" behavior). + if _, err := s.githubService.AssociatePRWithTask(ctx, taskID, watch.RepositoryID, foundPR); err != nil { s.logger.Error("failed to associate PR with task", zap.String("task_id", taskID), zap.Int("pr_number", foundPR.Number), @@ -516,25 +579,160 @@ func (s *Service) backfillRepoProvider(store repoStore, repo *models.Repository) } } -// ensureSessionPRWatch creates a PRWatch (pr_number=0) for a session's branch -// so the poller will search for a PR on GitHub. Runs as a background goroutine. -func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, branch string) { - branch = s.resolvePRWatchBranch(ctx, taskID, sessionID, branch) - if s.githubService == nil || branch == "" { +// ensureSessionPRWatch creates one PRWatch (pr_number=0) per repository in +// the task so the poller will search GitHub for each repo's PR. Runs as a +// background goroutine. +// +// Multi-repo: each repository gets its own watch row because the table is +// keyed UNIQUE(session_id, repository_id). Without this, only the primary +// repo's PR was ever discovered — secondary repos in a multi-repo task +// silently dropped their associations. +// +// `fallbackBranch` is used only when a task_repository has no checkout_branch +// AND the session worktree for that repo has no branch (rare; ensures +// backwards compatibility with single-repo callers that pass the resolved +// branch directly). +func (s *Service) ensureSessionPRWatch(ctx context.Context, taskID, sessionID, fallbackBranch string) { + if s.githubService == nil { return } - - owner, repoName := s.resolveTaskRepo(ctx, taskID) - if owner == "" || repoName == "" { - return + targets := s.resolveSessionWatchTargets(ctx, taskID, sessionID, fallbackBranch) + for _, t := range targets { + if _, err := s.githubService.EnsurePRWatch( + ctx, sessionID, taskID, t.RepositoryID, t.Owner, t.Repo, t.Branch, + ); err != nil { + s.logger.Warn("failed to ensure PR watch for session", + zap.String("session_id", sessionID), + zap.String("repository_id", t.RepositoryID), + zap.String("branch", t.Branch), + zap.Error(err)) + } } +} - if _, err := s.githubService.EnsurePRWatch(ctx, sessionID, taskID, owner, repoName, branch); err != nil { - s.logger.Warn("failed to ensure PR watch for session", - zap.String("session_id", sessionID), - zap.String("branch", branch), - zap.Error(err)) +// sessionWatchTarget describes one (session, repo) pair that should have a +// PR watch. Built by walking the task's repositories and matching each to +// the session's worktree (branch) and the repo (owner/name). +type sessionWatchTarget struct { + RepositoryID string + Owner string + Repo string + Branch string +} + +// resolveSessionWatchTargets walks the task's repositories and produces one +// target per repo whose owner/name and branch resolve. Skips repos for which +// any of those fields are empty — they can't be searched on GitHub. +// +// Branch resolution per repo (matches the legacy single-repo +// resolvePRWatchBranch priority so PR-review-style synthetic worktree +// branches don't override the task's real branch): +// +// 1. task_repository.checkout_branch (set at task creation — authoritative) +// 2. caller's fallbackBranch, but only for the primary repo (legacy signature) +// 3. session worktree's branch (last-resort guess from the worktree) +// +// Applying the fallback to non-primary repos would assign every repo the same +// branch and corrupt cross-repo watches. +func (s *Service) resolveSessionWatchTargets( + ctx context.Context, taskID, sessionID, fallbackBranch string, +) []sessionWatchTarget { + store, ok := s.repo.(repoStore) + if !ok { + return nil } + taskRepos, err := store.ListTaskRepositories(ctx, taskID) + if err != nil || len(taskRepos) == 0 { + return nil + } + branchByRepo := s.branchByRepoForSession(ctx, sessionID) + + var targets []sessionWatchTarget + for _, tr := range taskRepos { + owner, repoName := s.resolveRepoOwnerName(ctx, store, tr.RepositoryID) + if owner == "" || repoName == "" { + continue + } + branch := strings.TrimSpace(tr.CheckoutBranch) + if branch == "" && tr.Position == 0 { + branch = fallbackBranch + } + if branch == "" { + branch = branchByRepo[tr.RepositoryID] + } + if branch == "" { + continue + } + targets = append(targets, sessionWatchTarget{ + RepositoryID: tr.RepositoryID, + Owner: owner, + Repo: repoName, + Branch: branch, + }) + } + return targets +} + +// branchByRepoForSession returns repository_id → worktree_branch for every +// worktree on the session. Empty map if the session can't be loaded — caller +// falls back to the task_repository's configured branch. +func (s *Service) branchByRepoForSession(ctx context.Context, sessionID string) map[string]string { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + return nil + } + out := make(map[string]string, len(session.Worktrees)) + for _, wt := range session.Worktrees { + if wt.WorktreeBranch == "" || wt.RepositoryID == "" { + continue + } + out[wt.RepositoryID] = wt.WorktreeBranch + } + return out +} + +// resolveRepoOwnerName returns (owner, name) for a repository, backfilling +// provider info from the local git remote when missing. Mirrors what +// resolveTaskRepo does for the primary repo, but works against any repo id. +func (s *Service) resolveRepoOwnerName( + ctx context.Context, store repoStore, repositoryID string, +) (string, string) { + if repositoryID == "" { + return "", "" + } + repoObj, err := store.GetRepository(ctx, repositoryID) + if err != nil || repoObj == nil { + return "", "" + } + if repoObj.ProviderOwner == "" && repoObj.LocalPath != "" { + if p, o, n := service.ResolveGitRemoteProvider(repoObj.LocalPath); o != "" { + repoObj.Provider = p + repoObj.ProviderOwner = o + repoObj.ProviderName = n + go s.backfillRepoProvider(store, repoObj) + } + } + if repoObj.Provider != "github" { + return "", "" + } + return repoObj.ProviderOwner, repoObj.ProviderName +} + +// resolvePrimaryTaskRepositoryID returns the primary task_repository's +// repository_id for a task, or "" on miss / error. Used to scope PR +// watches and TaskPR rows to the correct per-repo row in multi-repo tasks +// (and to the single repo's row in single-repo tasks). Empty preserves the +// legacy single-repo behavior at the store layer. +func (s *Service) resolvePrimaryTaskRepositoryID(ctx context.Context, taskID string) string { + store, ok := s.repo.(repoStore) + if !ok { + return "" + } + taskRepo, err := store.GetPrimaryTaskRepository(ctx, taskID) + if err != nil || taskRepo == nil { + return "" + } + return taskRepo.RepositoryID } func (s *Service) resolvePRWatchBranch(ctx context.Context, taskID, sessionID, fallback string) string { @@ -594,24 +792,32 @@ func (s *Service) resolveTaskRepo(ctx context.Context, taskID string) (string, s return repoObj.ProviderOwner, repoObj.ProviderName } -// associatePRFromPush creates the PR watch and task-PR association after push detection. -func (s *Service) associatePRFromPush( - ctx context.Context, sessionID, taskID, owner, repoName, branch string, pr *github.PR, +// associatePRFromPushScoped creates the PR watch and task-PR association +// after push detection, scoped to a specific repository_id. Multi-repo callers +// pass the per-repo id resolved from the git event's repository_name; the +// legacy single-repo path passes the primary task_repository id. +func (s *Service) associatePRFromPushScoped( + ctx context.Context, sessionID, taskID, owner, repoName, repositoryID, branch string, pr *github.PR, ) { if _, watchErr := s.githubService.CreatePRWatch( - ctx, sessionID, taskID, owner, repoName, pr.Number, branch, + ctx, sessionID, taskID, repositoryID, owner, repoName, pr.Number, branch, ); watchErr != nil { s.logger.Error("failed to create PR watch on push detection", - zap.String("session_id", sessionID), zap.Error(watchErr)) + zap.String("session_id", sessionID), + zap.String("repository_id", repositoryID), + zap.Error(watchErr)) } - if _, assocErr := s.githubService.AssociatePRWithTask(ctx, taskID, pr); assocErr != nil { + if _, assocErr := s.githubService.AssociatePRWithTask(ctx, taskID, repositoryID, pr); assocErr != nil { s.logger.Error("failed to associate PR with task on push detection", - zap.String("task_id", taskID), zap.Error(assocErr)) + zap.String("task_id", taskID), + zap.String("repository_id", repositoryID), + zap.Error(assocErr)) } s.logger.Info("auto-detected PR from push", zap.String("session_id", sessionID), + zap.String("repository_id", repositoryID), zap.Int("pr_number", pr.Number), zap.String("branch", branch)) } @@ -642,7 +848,8 @@ func (s *Service) CheckSessionPR(ctx context.Context, taskID, sessionID string) } // Ensure a PR watch exists so the background poller will keep checking - if _, watchErr := s.githubService.EnsurePRWatch(ctx, sessionID, taskID, owner, repoName, branch); watchErr != nil { + repositoryID := s.resolvePrimaryTaskRepositoryID(ctx, taskID) + if _, watchErr := s.githubService.EnsurePRWatch(ctx, sessionID, taskID, repositoryID, owner, repoName, branch); watchErr != nil { s.logger.Warn("failed to ensure PR watch during check", zap.String("session_id", sessionID), zap.Error(watchErr)) @@ -659,7 +866,7 @@ func (s *Service) CheckSessionPR(ctx context.Context, taskID, sessionID string) } // Found a PR — associate it with the task - s.associatePRFromPush(ctx, sessionID, taskID, owner, repoName, branch, pr) + s.associatePRFromPushScoped(ctx, sessionID, taskID, owner, repoName, repositoryID, branch, pr) return true, nil } @@ -682,8 +889,10 @@ func (s *Service) ResolveBranchForSession(ctx context.Context, taskID, sessionID return s.resolvePRWatchBranch(ctx, taskID, sessionID, "") } -// buildTaskBranchList queries sessions with branches, filters out those with -// existing PR watches, and resolves GitHub owner/repo for the remainder. +// buildTaskBranchList walks sessions × their repositories and emits one +// TaskBranchInfo per (session, repository) that doesn't already have a PR +// watch. Multi-repo: previously dedup was keyed by sessionID, which silently +// dropped non-primary repos as soon as the primary one got a watch. func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]github.TaskBranchInfo, error) { sessions, err := store.ListSessionsWithBranches(ctx) if err != nil { @@ -694,34 +903,35 @@ func (s *Service) buildTaskBranchList(ctx context.Context, store repoStore) ([]g } // Batch fetch existing watches to avoid N+1 queries. - watchedSessions := s.buildWatchedSessionSet(ctx) + watchedKeys := s.buildWatchedSessionRepoSet(ctx) var result []github.TaskBranchInfo for _, sess := range sessions { - if watchedSessions[sess.SessionID] { - continue + // Pass sess.Branch as fallback for the primary repo (legacy callers + // stored the worktree branch on SessionBranchInfo); per-repo branches + // come from session.Worktrees inside resolveSessionWatchTargets. + targets := s.resolveSessionWatchTargets(ctx, sess.TaskID, sess.SessionID, sess.Branch) + for _, t := range targets { + if watchedKeys[watchedSessionRepoKey(sess.SessionID, t.RepositoryID)] { + continue + } + result = append(result, github.TaskBranchInfo{ + TaskID: sess.TaskID, + SessionID: sess.SessionID, + RepositoryID: t.RepositoryID, + Owner: t.Owner, + Repo: t.Repo, + Branch: t.Branch, + }) } - owner, repo := s.resolveTaskRepo(ctx, sess.TaskID) - if owner == "" || repo == "" { - continue - } - branch := s.resolvePRWatchBranch(ctx, sess.TaskID, sess.SessionID, sess.Branch) - if branch == "" { - continue - } - result = append(result, github.TaskBranchInfo{ - TaskID: sess.TaskID, - SessionID: sess.SessionID, - Owner: owner, - Repo: repo, - Branch: branch, - }) } return result, nil } -// buildWatchedSessionSet returns a set of session IDs that already have PR watches. -func (s *Service) buildWatchedSessionSet(ctx context.Context) map[string]bool { +// buildWatchedSessionRepoSet returns the set of (session_id, repository_id) +// pairs that already have a PR watch row. Replaces the older session-only set +// which collapsed multi-repo sessions into one bucket. +func (s *Service) buildWatchedSessionRepoSet(ctx context.Context) map[string]bool { watches, err := s.githubService.ListActivePRWatches(ctx) if err != nil { s.logger.Debug("failed to list PR watches for reconciliation", zap.Error(err)) @@ -729,11 +939,16 @@ func (s *Service) buildWatchedSessionSet(ctx context.Context) map[string]bool { } set := make(map[string]bool, len(watches)) for _, w := range watches { - set[w.SessionID] = true + set[watchedSessionRepoKey(w.SessionID, w.RepositoryID)] = true } return set } +// watchedSessionRepoKey builds the per-(session, repo) dedup key. +func watchedSessionRepoKey(sessionID, repositoryID string) string { + return sessionID + "|" + repositoryID +} + // subscribeGitHubEvents subscribes to GitHub-related events on the event bus. func (s *Service) subscribeGitHubEvents() { if s.eventBus == nil { diff --git a/apps/backend/internal/orchestrator/event_handlers_github_test.go b/apps/backend/internal/orchestrator/event_handlers_github_test.go index 563c99a36e..fc67e15504 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_test.go @@ -28,6 +28,12 @@ type mockGitHubService struct { createWatchBranch string updatedBranch string updatedPRNumber int + // repository_id captured by the most recent CreatePRWatch / + // AssociatePRWithTask call. Used by the multi-repo push tests to assert + // the per-repo scoping (an empty value indicates the legacy single-repo + // path landed on primary by accident). + lastCreateWatchRepositoryID string + lastAssociateRepositoryID string // Review PR reservation tracking. reserveCalls int @@ -49,7 +55,7 @@ func (m *mockGitHubService) Client() github.Client { return m.client } func (m *mockGitHubService) GetTaskPR(_ context.Context, _ string) (*github.TaskPR, error) { return m.taskPR, m.taskPRErr } -func (m *mockGitHubService) EnsurePRWatch(_ context.Context, _, _, _, _, branch string) (*github.PRWatch, error) { +func (m *mockGitHubService) EnsurePRWatch(_ context.Context, _, _, _, _, _, branch string) (*github.PRWatch, error) { m.ensureWatchCalls++ m.ensureWatchBranch = branch return &github.PRWatch{}, nil @@ -57,13 +63,18 @@ func (m *mockGitHubService) EnsurePRWatch(_ context.Context, _, _, _, _, branch func (m *mockGitHubService) GetPRWatchBySession(_ context.Context, _ string) (*github.PRWatch, error) { return m.prWatch, nil } -func (m *mockGitHubService) CreatePRWatch(_ context.Context, _, _, _, _ string, _ int, branch string) (*github.PRWatch, error) { +func (m *mockGitHubService) GetPRWatchBySessionAndRepo(_ context.Context, _, _ string) (*github.PRWatch, error) { + return m.prWatch, nil +} +func (m *mockGitHubService) CreatePRWatch(_ context.Context, _, _, repositoryID, _, _ string, _ int, branch string) (*github.PRWatch, error) { m.createWatchCalls++ m.createWatchBranch = branch + m.lastCreateWatchRepositoryID = repositoryID return &github.PRWatch{}, nil } -func (m *mockGitHubService) AssociatePRWithTask(_ context.Context, _ string, _ *github.PR) (*github.TaskPR, error) { +func (m *mockGitHubService) AssociatePRWithTask(_ context.Context, _, repositoryID string, _ *github.PR) (*github.TaskPR, error) { m.associateCalls++ + m.lastAssociateRepositoryID = repositoryID return &github.TaskPR{}, nil } func (m *mockGitHubService) UpdatePRWatchBranchIfSearching(_ context.Context, _, branch string) error { @@ -433,7 +444,7 @@ func TestDetectPushAndAssociatePR(t *testing.T) { ghSvc := &mockGitHubService{client: mockClient} svc.SetGitHubService(ghSvc) - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "feature-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "feature-branch") if ghSvc.associateCalls != 1 { t.Errorf("expected 1 AssociatePRWithTask call, got %d", ghSvc.associateCalls) @@ -454,7 +465,7 @@ func TestDetectPushAndAssociatePR(t *testing.T) { } svc.SetGitHubService(ghSvc) - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "feature-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "feature-branch") if ghSvc.associateCalls != 0 { t.Errorf("expected no AssociatePRWithTask calls when PR already found, got %d", ghSvc.associateCalls) @@ -475,7 +486,7 @@ func TestDetectPushAndAssociatePR(t *testing.T) { } svc.SetGitHubService(ghSvc) - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "feature-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "feature-branch") if ghSvc.associateCalls != 1 { t.Errorf("expected 1 AssociatePRWithTask call when pr_number=0, got %d", ghSvc.associateCalls) @@ -511,7 +522,7 @@ func TestDetectPushAndAssociatePR(t *testing.T) { svc.SetGitHubService(ghSvc) // Agent pushed from "new-branch" (different from watch's "old-branch") - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "new-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "new-branch") if ghSvc.updateBranchCalls != 1 { t.Errorf("expected 1 UpdatePRWatchBranch call, got %d", ghSvc.updateBranchCalls) @@ -537,12 +548,115 @@ func TestDetectPushAndAssociatePR(t *testing.T) { } svc.SetGitHubService(ghSvc) - svc.detectPushAndAssociatePR(ctx, "s1", "t1", "feature-branch") + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "", "feature-branch") if ghSvc.updateBranchCalls != 0 { t.Errorf("expected no UpdatePRWatchBranch calls when branch matches, got %d", ghSvc.updateBranchCalls) } }) + + // Multi-repo: when the push event carries a repository_name, the resolver + // must look up that specific task_repository (not the primary one) and + // associate the PR under its repository_id. Without this, a multi-repo + // task only ever gets one PR detected — the second repo's push silently + // drops or lands under the wrong repository_id. + t.Run("resolvePushRepo multi-repo: scopes to the named repository", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Two repos linked to the task; agent pushes to the secondary one. + primary := &models.Repository{ + ID: "repo-front", WorkspaceID: "ws1", Name: "frontend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "frontend", + CreatedAt: now, UpdatedAt: now, + } + secondary := &models.Repository{ + ID: "repo-back", WorkspaceID: "ws1", Name: "backend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "backend", + CreatedAt: now, UpdatedAt: now, + } + if err := repo.CreateRepository(ctx, primary); err != nil { + t.Fatalf("create primary: %v", err) + } + if err := repo.CreateRepository(ctx, secondary); err != nil { + t.Fatalf("create secondary: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "tr-1", TaskID: "t1", RepositoryID: "repo-front", Position: 0, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("link primary: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "tr-2", TaskID: "t1", RepositoryID: "repo-back", Position: 1, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("link secondary: %v", err) + } + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + mockClient := github.NewMockClient() + mockClient.AddPR(&github.PR{ + Number: 99, Title: "Backend PR", + HTMLURL: "https://github.com/myorg/backend/pull/99", + AuthorLogin: "alice", + RepoOwner: "myorg", RepoName: "backend", + HeadBranch: "feature-x", BaseBranch: "main", + }) + ghSvc := &mockGitHubService{client: mockClient} + svc.SetGitHubService(ghSvc) + + // Push event came from the "backend" repo subdir — must associate + // under repo-back, NOT repo-front. + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "backend", "feature-x") + + if ghSvc.associateCalls != 1 { + t.Fatalf("expected 1 AssociatePRWithTask call, got %d", ghSvc.associateCalls) + } + if ghSvc.lastAssociateRepositoryID != "repo-back" { + t.Errorf("expected association under repo-back, got %q", ghSvc.lastAssociateRepositoryID) + } + if ghSvc.createWatchCalls != 1 { + t.Errorf("expected 1 CreatePRWatch call, got %d", ghSvc.createWatchCalls) + } + if ghSvc.lastCreateWatchRepositoryID != "repo-back" { + t.Errorf("expected watch under repo-back, got %q", ghSvc.lastCreateWatchRepositoryID) + } + }) + + // Symmetric guard: an unknown repository_name (not linked to the task) + // must not fall back to the primary — silently mis-associating a PR is + // worse than dropping the event. + t.Run("resolvePushRepo multi-repo: drops events for unknown repository_name", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-front", WorkspaceID: "ws1", Name: "frontend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "frontend", + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "tr-1", TaskID: "t1", RepositoryID: "repo-front", Position: 0, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("link: %v", err) + } + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + ghSvc := &mockGitHubService{client: github.NewMockClient()} + svc.SetGitHubService(ghSvc) + + svc.detectPushAndAssociatePR(ctx, "s1", "t1", "ghost-repo", "feature-x") + + if ghSvc.associateCalls != 0 { + t.Errorf("expected 0 AssociatePRWithTask calls for unknown repo, got %d", ghSvc.associateCalls) + } + if ghSvc.createWatchCalls != 0 { + t.Errorf("expected 0 CreatePRWatch calls for unknown repo, got %d", ghSvc.createWatchCalls) + } + }) } func TestListTasksNeedingPRWatch(t *testing.T) { @@ -673,4 +787,140 @@ func TestListTasksNeedingPRWatch(t *testing.T) { t.Errorf("expected empty result when github service is nil, got %d", len(tasks)) } }) + + // Regression for task a7a59bd8: a multi-repo task only ever had a watch + // for its primary repo, so the poller never searched GitHub for the + // non-primary repo's PR. The fix iterates per (session, repo). + t.Run("multi-repo: emits one entry per repository", func(t *testing.T) { + testRepo := setupTestRepo(t) + seedSession(t, testRepo, "t1", "s1", "step1") + + // Two repos linked to one task, one worktree per repo with its own branch. + repos := []*models.Repository{ + { + ID: "repo-front", WorkspaceID: "ws1", Name: "frontend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "frontend", + CreatedAt: now, UpdatedAt: now, + }, + { + ID: "repo-back", WorkspaceID: "ws1", Name: "backend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "backend", + CreatedAt: now, UpdatedAt: now, + }, + } + for _, r := range repos { + if err := testRepo.CreateRepository(ctx, r); err != nil { + t.Fatalf("create repo %s: %v", r.ID, err) + } + } + links := []*models.TaskRepository{ + {ID: "tr-1", TaskID: "t1", RepositoryID: "repo-front", Position: 0, CreatedAt: now, UpdatedAt: now}, + {ID: "tr-2", TaskID: "t1", RepositoryID: "repo-back", Position: 1, CreatedAt: now, UpdatedAt: now}, + } + for _, l := range links { + if err := testRepo.CreateTaskRepository(ctx, l); err != nil { + t.Fatalf("link %s: %v", l.ID, err) + } + } + worktrees := []*models.TaskSessionWorktree{ + {ID: "wt-1", SessionID: "s1", WorktreeID: "wtree-1", RepositoryID: "repo-front", WorktreeBranch: "feat/frontend", CreatedAt: now}, + {ID: "wt-2", SessionID: "s1", WorktreeID: "wtree-2", RepositoryID: "repo-back", WorktreeBranch: "feat/backend", CreatedAt: now}, + } + for _, wt := range worktrees { + if err := testRepo.CreateTaskSessionWorktree(ctx, wt); err != nil { + t.Fatalf("worktree %s: %v", wt.ID, err) + } + } + + svc := createTestService(testRepo, newMockStepGetter(), newMockTaskRepo()) + // Mock the watch list as if frontend already has a watch (the + // primary repo). Pre-fix this would have suppressed both repos — + // fix: secondary repo still emits. + svc.SetGitHubService(&mockGitHubService{}) + + tasks, err := svc.ListTasksNeedingPRWatch(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Expect both repos to be emitted (no existing watches in the mock). + byRepo := map[string]github.TaskBranchInfo{} + for _, ti := range tasks { + byRepo[ti.RepositoryID] = ti + } + front, ok := byRepo["repo-front"] + if !ok { + t.Fatal("expected primary repo entry, missing") + } + if front.Owner != "myorg" || front.Repo != "frontend" || front.Branch != "feat/frontend" { + t.Errorf("primary entry mis-resolved: %+v", front) + } + back, ok := byRepo["repo-back"] + if !ok { + t.Fatal("expected secondary repo entry, missing — this was the regression") + } + if back.Owner != "myorg" || back.Repo != "backend" || back.Branch != "feat/backend" { + t.Errorf("secondary entry mis-resolved: %+v", back) + } + }) +} + +// TestEnsureSessionPRWatch_MultiRepo guards the same multi-repo gap on the +// session-start path: ensureSessionPRWatch (called when a session is launched +// or resumed) must create a watch row for every repo on the task, not only +// the primary one. Pre-fix only one EnsurePRWatch call landed regardless of +// repo count, so secondary repos waited indefinitely for the never-firing +// poller and never associated their PRs. +func TestEnsureSessionPRWatch_MultiRepo(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + for _, r := range []*models.Repository{ + { + ID: "repo-front", WorkspaceID: "ws1", Name: "frontend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "frontend", + CreatedAt: now, UpdatedAt: now, + }, + { + ID: "repo-back", WorkspaceID: "ws1", Name: "backend", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "backend", + CreatedAt: now, UpdatedAt: now, + }, + } { + if err := repo.CreateRepository(ctx, r); err != nil { + t.Fatalf("create repo: %v", err) + } + } + for _, l := range []*models.TaskRepository{ + {ID: "tr-1", TaskID: "t1", RepositoryID: "repo-front", Position: 0, CreatedAt: now, UpdatedAt: now}, + {ID: "tr-2", TaskID: "t1", RepositoryID: "repo-back", Position: 1, CreatedAt: now, UpdatedAt: now}, + } { + if err := repo.CreateTaskRepository(ctx, l); err != nil { + t.Fatalf("link: %v", err) + } + } + for _, wt := range []*models.TaskSessionWorktree{ + {ID: "wt-1", SessionID: "s1", WorktreeID: "wtree-1", RepositoryID: "repo-front", WorktreeBranch: "feat/frontend", CreatedAt: now}, + {ID: "wt-2", SessionID: "s1", WorktreeID: "wtree-2", RepositoryID: "repo-back", WorktreeBranch: "feat/backend", CreatedAt: now}, + } { + if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil { + t.Fatalf("worktree: %v", err) + } + } + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + ghSvc := &mockGitHubService{} + svc.SetGitHubService(ghSvc) + + svc.ensureSessionPRWatch(ctx, "t1", "s1", "ignored-fallback") + + if ghSvc.ensureWatchCalls != 2 { + t.Errorf("expected 2 EnsurePRWatch calls (one per repo), got %d", ghSvc.ensureWatchCalls) + } } diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index f97b766de6..e2831d7bb1 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -24,6 +24,7 @@ type executorStore interface { UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error // Task↔repo junction GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) + ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) // Session CreateTaskSession(ctx context.Context, session *models.TaskSession) error GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) @@ -48,6 +49,8 @@ type executorStore interface { GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error + CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error + ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) // Session history + plan (for context handover) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) @@ -257,6 +260,30 @@ type LaunchAgentRequest struct { // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") RepoName string // Repository name used as subdirectory inside the task directory + + // Repositories carries one entry per repository when the launch is multi-repo. + // When non-empty it is the source of truth and the legacy single-repo + // top-level fields above are populated from Repositories[0] for backwards + // compatibility with code paths that have not yet been updated. + Repositories []RepoSpec +} + +// RepoSpec describes one repository for a multi-repo task launch from the +// orchestrator. Mirrors lifecycle.RepoLaunchSpec; kept as a separate type so +// the orchestrator package does not need to import lifecycle types into its +// public API. +type RepoSpec struct { + RepositoryID string + RepositoryPath string + RepositoryURL string + RepoName string + BaseBranch string + CheckoutBranch string + WorktreeID string + WorktreeBranchPrefix string + PullBeforeWorktree bool + RepoSetupScript string + RepoCleanupScript string } // McpModeConfig activates config-mode MCP tools (workflow steps, agents, MCP @@ -284,6 +311,22 @@ type LaunchAgentResponse struct { WorktreeBranch string Metadata map[string]interface{} PrepareResult *lifecycle.EnvPrepareResult `json:"-"` // Carried from lifecycle.Launch for synchronous persistence + + // Worktrees is the per-repository preparer result list when the launch is + // multi-repo. Empty for single-repo launches; the legacy WorktreeID/Path/ + // Branch fields above mirror Worktrees[0] in that case. + Worktrees []RepoWorktreeResult +} + +// RepoWorktreeResult mirrors lifecycle.RepoWorktreeResult for the orchestrator +// API surface. One entry per repository prepared during a multi-repo launch. +type RepoWorktreeResult struct { + RepositoryID string + WorktreeID string + WorktreeBranch string + WorktreePath string + MainRepoGitDir string + ErrorMessage string } // TaskExecution tracks an active task execution diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index da692afbb4..f0be99510a 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -339,7 +339,22 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses executorID := opts.ExecutorID prompt := opts.Prompt startAgent := opts.StartAgent - // Fetch the session to get its configuration + // Serialise concurrent launches for the same session. Two callers reach + // this path on every task: PrepareTaskSession spawns a background launch + // (workspace only) the moment a session is created, and StartTaskWithSession + // is called when the agent is actually started (auto-start, user click). + // Without this lock both run env-prep + executionStore.Add in parallel and + // the second one fails at register with "already has an agent running + // (race resolved during register)" — visible in the UI as + // "Environment setup failed". Multi-repo amplifies this because the + // per-repo prep runs sequentially, widening the race window. + sessionLock := e.getSessionLock(sessionID) + sessionLock.Lock() + defer sessionLock.Unlock() + + // Re-fetch the session under the lock so the fast-path check below sees + // any AgentExecutionID the previous holder just persisted. Without the + // re-fetch we'd hold a stale snapshot and run a second full launch. session, err := e.repo.GetTaskSession(ctx, sessionID) if err != nil { e.logger.Error("failed to get session for launch", @@ -370,10 +385,19 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses zap.String("session_id", sessionID)) } - repoInfo, err := e.resolvePrimaryRepoInfo(ctx, task.ID) + allRepos, err := e.resolveAllRepoInfo(ctx, task.ID) if err != nil { return nil, err } + // Primary = first by Position. For repo-less tasks (e.g. quick chat), allRepos + // is empty and primary is a zero-value placeholder; downstream code already + // handles the missing-repo path. + var primaryRepo *repoInfo + if len(allRepos) > 0 { + primaryRepo = allRepos[0] + } else { + primaryRepo = &repoInfo{} + } // Resolve the env ID before LaunchAgent so the in-memory AgentExecution // is env-scoped from the first shell/layout request, not only after DB @@ -381,7 +405,7 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses existingEnv, _ := e.repo.GetTaskEnvironmentByTaskID(ctx, task.ID) assignLaunchTaskEnvironmentID(session, existingEnv) - req, execCfg, err := e.buildLaunchAgentRequest(ctx, task, session, agentProfileID, executorID, prompt, repoInfo) + req, execCfg, err := e.buildLaunchAgentRequest(ctx, task, session, agentProfileID, executorID, prompt, primaryRepo, allRepos) if err != nil { return nil, err } @@ -432,7 +456,7 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses e.captureBaseCommit(captureCtx, sid) }(sessionID) - return e.finalizeLaunch(ctx, task, session, agentProfileID, sessionID, repoInfo, resp, startAgent, execCfg) + return e.finalizeLaunch(ctx, task, session, agentProfileID, sessionID, primaryRepo, resp, startAgent, execCfg) } // handleLaunchFailure marks the session and task as FAILED and returns the original error. @@ -509,7 +533,9 @@ func assignLaunchTaskEnvironmentID(session *models.TaskSession, existingEnv *mod // buildLaunchAgentRequest constructs a LaunchAgentRequest for a new session launch, // applying executor config, repository/worktree settings, and remote docker URL as needed. -func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, session *models.TaskSession, agentProfileID, executorID, prompt string, repoInfo *repoInfo) (*LaunchAgentRequest, executorConfig, error) { +// allRepos carries every repository for the task in Position order; for single-repo +// or repo-less tasks it has length <=1 and the legacy single-repo path runs unchanged. +func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, session *models.TaskSession, agentProfileID, executorID, prompt string, repoInfo *repoInfo, allRepos []*repoInfo) (*LaunchAgentRequest, executorConfig, error) { metadata := cloneMetadata(task.Metadata) if session.ExecutorProfileID != "" { if metadata == nil { @@ -560,6 +586,14 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s return nil, execConfig, err } + // Multi-repo: when more than one repository is associated with the task, + // populate req.Repositories so the lifecycle preparer creates one worktree + // per repo. The legacy single-repo top-level fields above stay populated + // (mirroring the primary) for downstream code that has not been migrated. + if len(allRepos) > 1 { + req.Repositories = buildRepoSpecs(allRepos) + } + // Activate config-mode MCP tools when config_mode is set in session metadata. if isConfigModeSession(session) { req.McpMode = McpModeConfig @@ -579,6 +613,36 @@ func (e *Executor) applyContainerCredentials(ctx context.Context, req *LaunchAge e.injectGitHubTokenFromCLI(ctx, req) // Final fallback to local gh CLI } +// buildRepoSpecs converts resolved repoInfos into per-repo launch specs for +// the lifecycle layer. Used only when the task has more than one repository. +func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec { + out := make([]RepoSpec, 0, len(allRepos)) + for _, info := range allRepos { + spec := RepoSpec{ + RepositoryID: info.RepositoryID, + RepositoryPath: info.RepositoryPath, + BaseBranch: info.BaseBranch, + CheckoutBranch: info.CheckoutBranch, + WorktreeBranchPrefix: info.WorktreeBranchPrefix, + PullBeforeWorktree: info.PullBeforeWorktree, + } + if info.Repository != nil { + spec.RepoName = info.Repository.Name + spec.RepoSetupScript = info.Repository.SetupScript + spec.RepoCleanupScript = info.Repository.CleanupScript + } + // Containerized executors need a clone URL; reuse the same helper as + // the single-repo path (best-effort — skipped if Repository is nil). + if info.Repository != nil { + if u := repositoryCloneURL(info.Repository); u != "" { + spec.RepositoryURL = u + } + } + out = append(out, spec) + } + return out +} + // applyRepositoryConfig sets repository-related fields on the request and resolves clone URLs. func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, repoInfo *repoInfo, execConfig executorConfig, metadata map[string]interface{}) (map[string]interface{}, error) { if repoInfo.RepositoryPath != "" { @@ -821,20 +885,31 @@ func (e *Executor) injectHandoverIfNeeded(ctx context.Context, taskID, currentSe // persistTaskEnvironment so the rule lives in exactly one place — without // this, the update branch silently leaves workspace_path empty, which // produces ErrSessionWorkspaceNotReady forever in the env terminal handler. +// +// In task-directory mode the desired value is always the task root that holds +// every per-repo worktree as a sibling. The legacy single-repo path passes +// resp.WorktreePath as that repo's subdir, so the parent is the task root. +// The multi-repo path on the lifecycle adapter mirrors agentctl's WorkDir +// (already the task root) into resp.WorktreePath, so it must be used as-is — +// applying filepath.Dir would walk up one level too far and point at the +// /tasks holder instead. func computeWorkspacePath(req *LaunchAgentRequest, resp *LaunchAgentResponse) string { workspacePath := resp.WorktreePath if workspacePath == "" { workspacePath = req.RepositoryPath } - // Task directory mode: WorkspacePath = task root, WorktreePath = repo subdir. - if req.TaskDirName != "" && resp.WorktreePath != "" { - workspacePath = filepath.Dir(resp.WorktreePath) + if req.TaskDirName == "" || resp.WorktreePath == "" { + return workspacePath } - return workspacePath + if len(resp.Worktrees) > 1 { + return resp.WorktreePath + } + return filepath.Dir(resp.WorktreePath) } // persistTaskEnvironment creates or updates the task environment record after a successful launch. -// It also links the session to the environment via TaskEnvironmentID. +// It also links the session to the environment via TaskEnvironmentID. For +// multi-repo launches it additionally writes one TaskEnvironmentRepo row per repo. // // Serialised per-task: concurrent launches for the same task previously raced // here (each saw existingEnv == nil, each created a new row, both succeeded @@ -894,6 +969,8 @@ func (e *Executor) persistTaskEnvironment( zap.Error(err)) } session.TaskEnvironmentID = existingEnv.ID + // Persist per-repo rows for multi-repo launches that didn't have them yet. + e.persistTaskEnvironmentRepos(ctx, existingEnv.ID, resp.Worktrees) return } @@ -911,6 +988,11 @@ func (e *Executor) persistTaskEnvironment( WorktreeBranch: resp.WorktreeBranch, WorkspacePath: workspacePath, ContainerID: resp.ContainerID, + TaskDirName: req.TaskDirName, + } + // Embed per-repo rows in the same create transaction when multi-repo. + if len(resp.Worktrees) > 0 { + env.Repos = buildTaskEnvironmentRepos(resp.Worktrees) } if err := e.repo.CreateTaskEnvironment(ctx, env); err != nil { e.logger.Warn("failed to create task environment", @@ -920,3 +1002,60 @@ func (e *Executor) persistTaskEnvironment( } session.TaskEnvironmentID = env.ID } + +// buildTaskEnvironmentRepos converts per-repo worktree results into env-repo rows. +// TaskEnvironmentID is left blank — it is set by the env Create transaction. +func buildTaskEnvironmentRepos(worktrees []RepoWorktreeResult) []*models.TaskEnvironmentRepo { + out := make([]*models.TaskEnvironmentRepo, 0, len(worktrees)) + for i, w := range worktrees { + out = append(out, &models.TaskEnvironmentRepo{ + RepositoryID: w.RepositoryID, + WorktreeID: w.WorktreeID, + WorktreePath: w.WorktreePath, + WorktreeBranch: w.WorktreeBranch, + Position: i, + ErrorMessage: w.ErrorMessage, + }) + } + return out +} + +// persistTaskEnvironmentRepos inserts per-repo rows under an existing env id, +// skipping any (env, repo) pair that already exists. Used when an existing +// environment is reused (resume / re-launch on the same task). +func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string, worktrees []RepoWorktreeResult) { + if envID == "" || len(worktrees) == 0 { + return + } + existing, err := e.repo.ListTaskEnvironmentRepos(ctx, envID) + if err != nil { + e.logger.Warn("failed to list existing task_environment_repos before insert", + zap.String("env_id", envID), + zap.Error(err)) + return + } + have := make(map[string]bool, len(existing)) + for _, row := range existing { + have[row.RepositoryID] = true + } + for i, w := range worktrees { + if w.RepositoryID == "" || have[w.RepositoryID] { + continue + } + row := &models.TaskEnvironmentRepo{ + TaskEnvironmentID: envID, + RepositoryID: w.RepositoryID, + WorktreeID: w.WorktreeID, + WorktreePath: w.WorktreePath, + WorktreeBranch: w.WorktreeBranch, + Position: i, + ErrorMessage: w.ErrorMessage, + } + if createErr := e.repo.CreateTaskEnvironmentRepo(ctx, row); createErr != nil { + e.logger.Warn("failed to persist task environment repo", + zap.String("env_id", envID), + zap.String("repository_id", w.RepositoryID), + zap.Error(createErr)) + } + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor_execute_workspace_path_test.go b/apps/backend/internal/orchestrator/executor/executor_execute_workspace_path_test.go new file mode 100644 index 0000000000..a455fba93b --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_execute_workspace_path_test.go @@ -0,0 +1,52 @@ +package executor + +import "testing" + +// Pinpointed tests for computeWorkspacePath. The persisted workspace_path +// becomes agentctl's WorkDir on cold start (GetOrEnsureExecution); pointing it +// at the wrong directory disables the per-repo tracker fan-out and silently +// drops the Changes panel back to single-repo mode. +func TestResolveTaskEnvWorkspacePath(t *testing.T) { + t.Parallel() + t.Run("multi-repo keeps task root", func(t *testing.T) { + req := &LaunchAgentRequest{TaskDirName: "do-nothing_mvo"} + resp := &LaunchAgentResponse{ + // Lifecycle adapter mirrors agentctl WorkDir (task root) into + // the legacy WorktreePath field for multi-repo launches. + WorktreePath: "/tmp/tasks/do-nothing_mvo", + Worktrees: []RepoWorktreeResult{ + {WorktreePath: "/tmp/tasks/do-nothing_mvo/kandev"}, + {WorktreePath: "/tmp/tasks/do-nothing_mvo/thm"}, + }, + } + if got := computeWorkspacePath(req, resp); got != "/tmp/tasks/do-nothing_mvo" { + t.Fatalf("multi-repo: want /tmp/tasks/do-nothing_mvo, got %q", got) + } + }) + + t.Run("single-repo derives task root from repo subdir", func(t *testing.T) { + req := &LaunchAgentRequest{TaskDirName: "fix-thing_abc"} + resp := &LaunchAgentResponse{ + WorktreePath: "/tmp/tasks/fix-thing_abc/kandev", + } + if got := computeWorkspacePath(req, resp); got != "/tmp/tasks/fix-thing_abc" { + t.Fatalf("single-repo: want /tmp/tasks/fix-thing_abc, got %q", got) + } + }) + + t.Run("non-task-dir mode passes worktree path through", func(t *testing.T) { + req := &LaunchAgentRequest{} // TaskDirName empty + resp := &LaunchAgentResponse{WorktreePath: "/legacy/worktrees/xyz"} + if got := computeWorkspacePath(req, resp); got != "/legacy/worktrees/xyz" { + t.Fatalf("non-task-dir: want /legacy/worktrees/xyz, got %q", got) + } + }) + + t.Run("no worktree falls back to repository path", func(t *testing.T) { + req := &LaunchAgentRequest{RepositoryPath: "/repos/myrepo"} + resp := &LaunchAgentResponse{} // no WorktreePath + if got := computeWorkspacePath(req, resp); got != "/repos/myrepo" { + t.Fatalf("fallback: want /repos/myrepo, got %q", got) + } + }) +} diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 6d23ddfcb9..19738fb188 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -174,14 +174,16 @@ func (m *mockAgentManager) WaitForAgentctlReady(ctx context.Context, sessionID s // mockRepository implements executorStore for testing type mockRepository struct { - mu sync.Mutex - sessions map[string]*models.TaskSession - tasks map[string]*models.Task - taskRepositories map[string]*models.TaskRepository - repositories map[string]*models.Repository - executors map[string]*models.Executor - executorsRunning map[string]*models.ExecutorRunning - taskEnvironments map[string]*models.TaskEnvironment + mu sync.Mutex + sessions map[string]*models.TaskSession + tasks map[string]*models.Task + taskRepositories map[string]*models.TaskRepository + repositories map[string]*models.Repository + executors map[string]*models.Executor + executorsRunning map[string]*models.ExecutorRunning + taskEnvironments map[string]*models.TaskEnvironment + taskEnvironmentRepos map[string][]*models.TaskEnvironmentRepo // env_id → rows + sessionWorktrees []*models.TaskSessionWorktree // Optional hook to inject behavior into GetTaskSession (e.g. simulate a // transient DB error); if nil, the default map lookup is used. @@ -197,13 +199,14 @@ type mockRepository struct { func newMockRepository() *mockRepository { return &mockRepository{ - sessions: make(map[string]*models.TaskSession), - tasks: make(map[string]*models.Task), - taskRepositories: make(map[string]*models.TaskRepository), - repositories: make(map[string]*models.Repository), - executors: make(map[string]*models.Executor), - executorsRunning: make(map[string]*models.ExecutorRunning), - taskEnvironments: make(map[string]*models.TaskEnvironment), + sessions: make(map[string]*models.TaskSession), + tasks: make(map[string]*models.Task), + taskRepositories: make(map[string]*models.TaskRepository), + repositories: make(map[string]*models.Repository), + executors: make(map[string]*models.Executor), + executorsRunning: make(map[string]*models.ExecutorRunning), + taskEnvironments: make(map[string]*models.TaskEnvironment), + taskEnvironmentRepos: make(map[string][]*models.TaskEnvironmentRepo), } } @@ -294,7 +297,8 @@ func (m *mockRepository) UpsertExecutorRunning(ctx context.Context, running *mod return nil } -func (m *mockRepository) CreateTaskSessionWorktree(ctx context.Context, worktree *models.TaskSessionWorktree) error { +func (m *mockRepository) CreateTaskSessionWorktree(_ context.Context, worktree *models.TaskSessionWorktree) error { + m.sessionWorktrees = append(m.sessionWorktrees, worktree) return nil } @@ -358,7 +362,19 @@ func (m *mockRepository) GetTaskRepository(ctx context.Context, id string) (*mod return nil, nil } func (m *mockRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { - return nil, nil + var out []*models.TaskRepository + for _, tr := range m.taskRepositories { + if tr.TaskID == taskID { + out = append(out, tr) + } + } + // Stable order by Position so callers (and tests) see deterministic results. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Position < out[j-1].Position; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out, nil } func (m *mockRepository) ListTaskRepositoriesByTaskIDs(_ context.Context, _ []string) (map[string][]*models.TaskRepository, error) { return make(map[string][]*models.TaskRepository), nil @@ -612,19 +628,46 @@ func (m *mockRepository) ListEnvironments(ctx context.Context) ([]*models.Enviro } // Task environment operations -func (m *mockRepository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { - return m.taskEnvironments[taskID], nil +func (m *mockRepository) GetTaskEnvironmentByTaskID(_ context.Context, taskID string) (*models.TaskEnvironment, error) { + for _, env := range m.taskEnvironments { + if env.TaskID == taskID { + return env, nil + } + } + return nil, nil } -func (m *mockRepository) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { +func (m *mockRepository) CreateTaskEnvironment(_ context.Context, env *models.TaskEnvironment) error { + if env.ID == "" { + env.ID = "env-" + env.TaskID + } m.createTaskEnvironmentCalls = append(m.createTaskEnvironmentCalls, env) - m.taskEnvironments[env.TaskID] = env + m.taskEnvironments[env.ID] = env + for _, r := range env.Repos { + r.TaskEnvironmentID = env.ID + if r.ID == "" { + r.ID = env.ID + "-repo-" + r.RepositoryID + } + } + if len(env.Repos) > 0 { + m.taskEnvironmentRepos[env.ID] = append(m.taskEnvironmentRepos[env.ID], env.Repos...) + } return nil } -func (m *mockRepository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { +func (m *mockRepository) UpdateTaskEnvironment(_ context.Context, env *models.TaskEnvironment) error { + if env.ID == "" { + return nil + } m.updateTaskEnvironmentCalls = append(m.updateTaskEnvironmentCalls, env) - m.taskEnvironments[env.TaskID] = env + m.taskEnvironments[env.ID] = env return nil } +func (m *mockRepository) CreateTaskEnvironmentRepo(_ context.Context, repo *models.TaskEnvironmentRepo) error { + m.taskEnvironmentRepos[repo.TaskEnvironmentID] = append(m.taskEnvironmentRepos[repo.TaskEnvironmentID], repo) + return nil +} +func (m *mockRepository) ListTaskEnvironmentRepos(_ context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) { + return m.taskEnvironmentRepos[envID], nil +} // Task Plan operations func (m *mockRepository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { return nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go b/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go new file mode 100644 index 0000000000..ed52456ee8 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go @@ -0,0 +1,204 @@ +package executor + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// seedMultiRepoTask wires the mock repository with two repositories linked to +// taskID, returning the captured launch request after LaunchPreparedSession. +func seedMultiRepoTask(t *testing.T, repo *mockRepository, taskID string) { + t.Helper() + repo.repositories["repo-front"] = &models.Repository{ + ID: "repo-front", + Name: "frontend", + LocalPath: "/repos/frontend", + WorktreeBranchPrefix: "feat/", + } + repo.repositories["repo-back"] = &models.Repository{ + ID: "repo-back", + Name: "backend", + LocalPath: "/repos/backend", + WorktreeBranchPrefix: "feat/", + } + repo.taskRepositories["tr-1"] = &models.TaskRepository{ + ID: "tr-1", TaskID: taskID, RepositoryID: "repo-front", Position: 0, BaseBranch: "main", + } + repo.taskRepositories["tr-2"] = &models.TaskRepository{ + ID: "tr-2", TaskID: taskID, RepositoryID: "repo-back", Position: 1, BaseBranch: "main", + } +} + +func TestLaunchPreparedSession_MultiRepo_PopulatesRequestRepositories(t *testing.T) { + repo := newMockRepository() + taskID := "task-multi-1" + sessionID := "session-multi-1" + seedMultiRepoTask(t, repo, taskID) + + repo.sessions[sessionID] = &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + + var captured *LaunchAgentRequest + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + captured = req + return &LaunchAgentResponse{ + AgentExecutionID: "exec-1", + Worktrees: []RepoWorktreeResult{ + {RepositoryID: "repo-front", WorktreeID: "wt-front", WorktreeBranch: "feat/x-1", WorktreePath: "/tasks/x/frontend"}, + {RepositoryID: "repo-back", WorktreeID: "wt-back", WorktreeBranch: "feat/x-2", WorktreePath: "/tasks/x/backend"}, + }, + }, nil + }, + } + exec := newTestExecutor(t, agentManager, repo) + + task := &v1.Task{ID: taskID, WorkspaceID: "ws-1", Title: "Multi"} + _, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{ + AgentProfileID: "profile-123", + StartAgent: false, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession: %v", err) + } + + if captured == nil { + t.Fatal("expected launch agent to be called") + } + if len(captured.Repositories) != 2 { + t.Fatalf("expected req.Repositories length 2, got %d", len(captured.Repositories)) + } + if captured.Repositories[0].RepositoryID != "repo-front" || captured.Repositories[1].RepositoryID != "repo-back" { + t.Errorf("unexpected repo order: %+v", captured.Repositories) + } + // Legacy single-repo top-level fields stay populated from the primary. + if captured.RepositoryPath != "/repos/frontend" { + t.Errorf("expected primary repo path on top-level field, got %q", captured.RepositoryPath) + } +} + +func TestLaunchPreparedSession_MultiRepo_PersistsPerRepoEnvironmentAndWorktreeRows(t *testing.T) { + repo := newMockRepository() + taskID := "task-multi-2" + sessionID := "session-multi-2" + seedMultiRepoTask(t, repo, taskID) + + repo.sessions[sessionID] = &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, _ *LaunchAgentRequest) (*LaunchAgentResponse, error) { + return &LaunchAgentResponse{ + AgentExecutionID: "exec-2", + WorktreeID: "wt-front", // legacy mirror of Worktrees[0] + WorktreePath: "/tasks/x/frontend", + Worktrees: []RepoWorktreeResult{ + {RepositoryID: "repo-front", WorktreeID: "wt-front", WorktreeBranch: "feat/x-1", WorktreePath: "/tasks/x/frontend"}, + {RepositoryID: "repo-back", WorktreeID: "wt-back", WorktreeBranch: "feat/x-2", WorktreePath: "/tasks/x/backend"}, + }, + PrepareResult: &lifecycle.EnvPrepareResult{ + Success: true, + Worktrees: []lifecycle.RepoWorktreeResult{ + {RepositoryID: "repo-front", WorktreeID: "wt-front"}, + {RepositoryID: "repo-back", WorktreeID: "wt-back"}, + }, + }, + }, nil + }, + } + exec := newTestExecutor(t, agentManager, repo) + + task := &v1.Task{ID: taskID, WorkspaceID: "ws-1", Title: "Multi"} + _, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{ + AgentProfileID: "profile-123", + StartAgent: false, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession: %v", err) + } + + // One TaskEnvironment row + 2 TaskEnvironmentRepo rows. + if len(repo.taskEnvironments) != 1 { + t.Fatalf("expected 1 task_environment, got %d", len(repo.taskEnvironments)) + } + var envID string + for id := range repo.taskEnvironments { + envID = id + } + if got := len(repo.taskEnvironmentRepos[envID]); got != 2 { + t.Errorf("expected 2 task_environment_repos, got %d", got) + } + + // Two TaskSessionWorktree rows, one per repo. + if len(repo.sessionWorktrees) != 2 { + t.Fatalf("expected 2 session_worktree rows, got %d", len(repo.sessionWorktrees)) + } + repoIDsSeen := map[string]bool{} + for _, w := range repo.sessionWorktrees { + repoIDsSeen[w.RepositoryID] = true + } + if !repoIDsSeen["repo-front"] || !repoIDsSeen["repo-back"] { + t.Errorf("expected both repo IDs persisted; got %v", repoIDsSeen) + } +} + +func TestLaunchPreparedSession_SingleRepo_DoesNotPopulateRequestRepositories(t *testing.T) { + repo := newMockRepository() + taskID := "task-single-1" + sessionID := "session-single-1" + repo.repositories["repo-only"] = &models.Repository{ + ID: "repo-only", Name: "only", LocalPath: "/repos/only", WorktreeBranchPrefix: "feat/", + } + repo.taskRepositories["tr-only"] = &models.TaskRepository{ + ID: "tr-only", TaskID: taskID, RepositoryID: "repo-only", Position: 0, BaseBranch: "main", + } + repo.sessions[sessionID] = &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + AgentProfileID: "profile-123", + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + + var captured *LaunchAgentRequest + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + captured = req + return &LaunchAgentResponse{AgentExecutionID: "exec-3"}, nil + }, + } + exec := newTestExecutor(t, agentManager, repo) + + task := &v1.Task{ID: taskID, WorkspaceID: "ws-1"} + _, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{ + AgentProfileID: "profile-123", + StartAgent: false, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession: %v", err) + } + if len(captured.Repositories) != 0 { + t.Errorf("single-repo launch should not populate Repositories list; got %d entries", len(captured.Repositories)) + } + if captured.RepositoryPath != "/repos/only" { + t.Errorf("expected legacy RepositoryPath populated; got %q", captured.RepositoryPath) + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index cd3400b530..86f6b3310c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -54,9 +54,43 @@ func (e *Executor) resolvePrimaryRepoInfo(ctx context.Context, taskID string) (* if primaryTaskRepo == nil { return info, nil } - info.RepositoryID = primaryTaskRepo.RepositoryID - info.BaseBranch = primaryTaskRepo.BaseBranch - info.CheckoutBranch = primaryTaskRepo.CheckoutBranch + return e.resolveTaskRepoInfo(ctx, primaryTaskRepo) +} + +// resolveAllRepoInfo returns the resolved repository info for every repository +// linked to the task, ordered by Position. Returns a single-element slice for +// single-repo tasks and an empty slice for repo-less tasks (e.g. quick chat). +// Each entry has LocalPath populated, cloning provider-backed repos on demand. +func (e *Executor) resolveAllRepoInfo(ctx context.Context, taskID string) ([]*repoInfo, error) { + taskRepos, err := e.repo.ListTaskRepositories(ctx, taskID) + if err != nil { + e.logger.Error("failed to list task repositories", + zap.String("task_id", taskID), + zap.Error(err)) + return nil, err + } + if len(taskRepos) == 0 { + return nil, nil + } + out := make([]*repoInfo, 0, len(taskRepos)) + for _, tr := range taskRepos { + info, resolveErr := e.resolveTaskRepoInfo(ctx, tr) + if resolveErr != nil { + return nil, resolveErr + } + out = append(out, info) + } + return out, nil +} + +// resolveTaskRepoInfo turns a TaskRepository row into a fully-resolved repoInfo +// (loads the Repository entity, clones if necessary, fills defaults). +func (e *Executor) resolveTaskRepoInfo(ctx context.Context, tr *models.TaskRepository) (*repoInfo, error) { + info := &repoInfo{ + RepositoryID: tr.RepositoryID, + BaseBranch: tr.BaseBranch, + CheckoutBranch: tr.CheckoutBranch, + } if info.RepositoryID == "" { return info, nil } @@ -207,6 +241,38 @@ func buildPrepareResultMetadata(result *lifecycle.EnvPrepareResult) map[string]i } func (e *Executor) persistWorktreeAssociation(ctx context.Context, taskID string, session *models.TaskSession, repositoryID string, resp *LaunchAgentResponse) { + // Multi-repo path: persist one TaskSessionWorktree row per per-repo worktree + // returned by the preparer. Each row carries its own RepositoryID so + // downstream lookups can scope by repo. + if len(resp.Worktrees) > 0 { + existingIDs := make(map[string]bool, len(session.Worktrees)) + for _, wt := range session.Worktrees { + existingIDs[wt.WorktreeID] = true + } + for i, w := range resp.Worktrees { + if w.WorktreeID == "" || existingIDs[w.WorktreeID] { + continue + } + row := &models.TaskSessionWorktree{ + SessionID: session.ID, + WorktreeID: w.WorktreeID, + RepositoryID: w.RepositoryID, + Position: i, + WorktreePath: w.WorktreePath, + WorktreeBranch: w.WorktreeBranch, + } + if err := e.repo.CreateTaskSessionWorktree(ctx, row); err != nil { + e.logger.Error("failed to persist session worktree association", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.String("worktree_id", w.WorktreeID), + zap.String("repository_id", w.RepositoryID), + zap.Error(err)) + } + } + return + } + if resp.WorktreeID == "" { return } @@ -597,6 +663,25 @@ func (e *Executor) applyResumeRepoConfig(ctx context.Context, task *v1.Task, ses req.PullBeforeWorktree = repository.PullBeforeWorktree } + // Multi-repo: when the task has more than one repository, populate + // req.Repositories so the lifecycle preparer can resume/recreate each + // repo's worktree. The legacy top-level fields above stay populated + // from the primary for backwards compat. + if len(task.Repositories) > 1 { + allRepos, allErr := e.resolveAllRepoInfo(ctx, task.ID) + if allErr != nil { + return repositoryID, allErr + } + if len(allRepos) > 1 { + req.Repositories = buildRepoSpecs(allRepos) + // Stamp the per-repo TaskDirName so the preparer reuses the same + // task root that the original launch created. + if env, _ := e.repo.GetTaskEnvironmentByTaskID(ctx, task.ID); env != nil && env.TaskDirName != "" { + req.TaskDirName = env.TaskDirName + } + } + } + return repositoryID, nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 7fbe9b4c01..65250c4910 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -3,6 +3,7 @@ package executor import ( "context" "fmt" + "sync" "sync/atomic" "testing" "time" @@ -800,3 +801,98 @@ func TestPersistResumeState_SetsStartingState(t *testing.T) { } }) } + +// Regression: PrepareTaskSession launches the workspace in a background +// goroutine while StartTaskWithSession runs a foreground launch when the +// agent is started. Both call LaunchPreparedSession on the same session. +// Without per-session serialisation the two launches both reach +// agentManager.LaunchAgent and the second one errors with +// "race resolved during register" after running env prep — surfacing as +// "Environment setup failed" in the UI. Multi-repo amplifies the window +// because env prep runs sequentially per repo. +func TestLaunchPreparedSession_SerialisesConcurrentLaunches(t *testing.T) { + repo := newMockRepository() + session := &models.TaskSession{ + ID: "session-race", + TaskID: "task-race", + AgentProfileID: "profile-1", + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.sessions[session.ID] = session + + // `entered` fires every time LaunchAgent is hit. `gate` blocks the + // caller until the test releases it, keeping the first launch holding + // the per-session lock so a parallel call would otherwise race in. + // Channel-based coordination per CLAUDE.md (no time.Sleep). + var launchCount int64 + entered := make(chan struct{}, 2) + gate := make(chan struct{}) + agentManager := &mockAgentManager{ + launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + atomic.AddInt64(&launchCount, 1) + entered <- struct{}{} + <-gate + // Simulate the first caller persisting the agent execution id — + // the second caller's re-fetch under the lock will see it and + // take the fast path instead of launching again. + repo.sessions[req.SessionID].AgentExecutionID = "exec-race" + return &LaunchAgentResponse{AgentExecutionID: "exec-race", Status: v1.AgentStatusStarting}, nil + }, + // Fast path lookup must succeed for the second caller; mirror what + // the live store would return after the first caller registered. + getExecutionIDForSessionFunc: func(ctx context.Context, sessionID string) (string, error) { + return "exec-race", nil + }, + } + executor := newTestExecutor(t, agentManager, repo) + task := &v1.Task{ID: "task-race", WorkspaceID: "ws-1"} + + var wg sync.WaitGroup + results := make([]error, 2) + for i := range results { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, err := executor.LaunchPreparedSession(context.Background(), task, "session-race", LaunchOptions{ + AgentProfileID: "profile-1", + StartAgent: true, + }) + results[idx] = err + }(i) + } + + // Wait for the first launch to enter LaunchAgent and block on the + // gate. With the per-session lock the second goroutine cannot proceed + // past the lock acquisition, so a second `entered` send must NOT + // happen within a bounded window. Without the lock, both goroutines + // race in well within the timeout (~ms). + <-entered + select { + case <-entered: + close(gate) + wg.Wait() + t.Fatalf("LaunchAgent entered twice — second caller raced past the per-session lock") + case <-time.After(100 * time.Millisecond): + // No second entry = the lock is holding the second caller. + } + if got := atomic.LoadInt64(&launchCount); got != 1 { + close(gate) + wg.Wait() + t.Fatalf("LaunchAgent call count before gate release = %d, want 1 (lock should have serialised both)", got) + } + close(gate) + wg.Wait() + + // First call ran LaunchAgent; second call took the fast path so total + // stays at 1. Both return non-error (the second is a no-op start). + if got := atomic.LoadInt64(&launchCount); got != 1 { + t.Errorf("LaunchAgent total calls = %d, want 1", got) + } + for i, err := range results { + if err != nil { + t.Errorf("results[%d] = %v, want nil", i, err) + } + } +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index e89717edb7..3e2b59c3d1 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -118,6 +118,7 @@ type repoStore interface { // Additional methods needed by executor UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) + ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) CreateTaskSession(ctx context.Context, session *models.TaskSession) error UpdateTaskSession(ctx context.Context, session *models.TaskSession) error ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) @@ -127,6 +128,9 @@ type repoStore interface { GetRepository(ctx context.Context, id string) (*models.Repository, error) UpdateRepository(ctx context.Context, repository *models.Repository) error GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) + // Multi-repo task environment children + CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error + ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) // Session history + plan (for context handover) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) } @@ -226,7 +230,12 @@ type Service struct { // Clarification canceller — cancels pending clarifications when agent's turn completes clarificationCanceller ClarificationCanceller - // Push tracker: sessionID -> last known ahead count + // Push tracker: "|" -> last known ahead count. + // The repository_name segment is required for multi-repo: each repo emits + // its own git status events, and keying by sessionID alone made events + // from different repos overwrite each other's ahead counts (so only one + // push got detected per session). Single-repo / repo-less sessions key + // against an empty repository_name. pushTracker sync.Map // gitSnapshotCache throttles per-session writes of the live git status diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index fe3d0ae7fd..7d009c1954 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -1308,6 +1308,9 @@ func (s *Service) DeleteSession(ctx context.Context, sessionID string) error { if s.gitSnapshotCache != nil { s.gitSnapshotCache.forget(sessionID) } + // Same reasoning for the push-detection tracker. Multi-repo sessions + // accumulate one entry per repo; pushTrackerForget walks them all. + s.pushTrackerForget(sessionID) // Auto-promote another session if we deleted the primary if wasPrimary { diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index fd9760879f..75d779082d 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -355,6 +355,21 @@ func (m *mockRepository) DeleteTaskEnvironment(ctx context.Context, id string) e func (m *mockRepository) DeleteTaskEnvironmentsByTask(ctx context.Context, taskID string) error { return nil } +func (m *mockRepository) CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { + return nil +} +func (m *mockRepository) ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) { + return nil, nil +} +func (m *mockRepository) UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { + return nil +} +func (m *mockRepository) DeleteTaskEnvironmentRepo(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) DeleteTaskEnvironmentReposByEnv(ctx context.Context, envID string) error { + return nil +} // Git Snapshot operations func (m *mockRepository) CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error { diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 3971517169..25a020d687 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -39,7 +39,13 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { api.GET("/workspaces/:id/repositories", h.httpListRepositories) api.POST("/workspaces/:id/repositories", h.httpCreateRepository) api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories) - api.GET("/workspaces/:id/repositories/branches", h.httpListLocalRepositoryBranches) + // Unified branch listing — accepts either ?repository_id= for an imported + // workspace repo, or ?path= for an on-machine folder discovered but not + // yet imported. Both paths bottom out in `listGitBranches`; the only + // difference is how the absolute path is resolved. + api.GET("/workspaces/:id/branches", h.httpListBranches) + // Local-status (branch + dirty files) backs the fresh-branch consent + // 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("/repositories/:id", h.httpGetRepository) @@ -152,35 +158,43 @@ func (h *RepositoryHandlers) httpValidateRepositoryPath(c *gin.Context) { }) } -func (h *RepositoryHandlers) httpListLocalRepositoryBranches(c *gin.Context) { +// httpListBranches handles the unified branch endpoint: +// +// GET /api/v1/workspaces/:id/branches?repository_id=X +// GET /api/v1/workspaces/:id/branches?path=/abs/path +// +// Exactly one of the two query params must be set. Both bottom out in the +// same `listGitBranches` call; the difference is just where the absolute +// path is resolved from (DB row vs request param). +func (h *RepositoryHandlers) httpListBranches(c *gin.Context) { + repoID := c.Query("repository_id") path := c.Query("path") - if path == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) + if repoID == "" && path == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository_id or path is required"}) return } - branches, err := h.service.ListLocalRepositoryBranches(c.Request.Context(), path) + if repoID != "" && path != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "specify only one of repository_id or path"}) + return + } + result, err := h.service.ListBranchesWithCurrent(c.Request.Context(), repoID, path) if err != nil { if errors.Is(err, service.ErrPathNotAllowed) { c.JSON(http.StatusBadRequest, gin.H{"error": "path is not within allowed roots"}) return } - h.logger.Error("failed to list local repository branches", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list local repository branches"}) + h.logger.Error("failed to list branches", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list branches"}) return } - current, err := h.service.LocalRepositoryCurrentBranch(c.Request.Context(), path) - if err != nil { - // Branches succeeded — current branch is best-effort metadata for the UI. - h.logger.Debug("failed to read current branch", zap.Error(err)) - } - dtoBranches := make([]dto.BranchDTO, len(branches)) - for i, branch := range branches { + dtoBranches := make([]dto.BranchDTO, len(result.Branches)) + for i, branch := range result.Branches { dtoBranches[i] = dto.FromBranch(branch) } c.JSON(http.StatusOK, dto.RepositoryBranchesResponse{ Branches: dtoBranches, Total: len(dtoBranches), - CurrentBranch: current, + CurrentBranch: result.CurrentBranch, }) } @@ -288,7 +302,8 @@ func (h *RepositoryHandlers) httpListRepositoryBranches(c *gin.Context) { fetchedAt, fetchError = h.refreshBranchesAtPath(ctx, repoID, repo.LocalPath) } - branches, err := h.service.ListLocalRepositoryBranches(ctx, repo.LocalPath) + // Use the unified branch lister (HEAD's API) with empty repoID + path. + branches, err := h.service.ListBranches(ctx, "", repo.LocalPath) if err != nil { if errors.Is(err, service.ErrPathNotAllowed) { c.JSON(http.StatusBadRequest, gin.H{"error": "repository path is not allowed"}) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index e68257c5b4..2f8ac0093f 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -545,7 +545,7 @@ const ( type TaskEnvironment struct { ID string `json:"id"` TaskID string `json:"task_id"` - RepositoryID string `json:"repository_id"` + RepositoryID string `json:"repository_id"` // Deprecated: use Repos. Kept for dual-write/backwards compat. ExecutorType string `json:"executor_type"` ExecutorID string `json:"executor_id"` ExecutorProfileID string `json:"executor_profile_id"` @@ -553,18 +553,57 @@ type TaskEnvironment struct { ControlPort int `json:"control_port"` // agentctl control port Status TaskEnvironmentStatus `json:"status"` - // Type-specific fields - WorktreeID string `json:"worktree_id,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` + // Type-specific fields. The single worktree fields below are legacy: with + // multi-repo tasks, the per-repo worktrees live on Repos. WorkspacePath + // continues to point at the agent workspace root (the task root when + // TaskDirName is set, otherwise the single repo's worktree path). + WorktreeID string `json:"worktree_id,omitempty"` // Deprecated: use Repos[i].WorktreeID + WorktreePath string `json:"worktree_path,omitempty"` // Deprecated: use Repos[i].WorktreePath + WorktreeBranch string `json:"worktree_branch,omitempty"` // Deprecated: use Repos[i].WorktreeBranch WorkspacePath string `json:"workspace_path,omitempty"` ContainerID string `json:"container_id,omitempty"` SandboxID string `json:"sandbox_id,omitempty"` + // TaskDirName is the semantic directory name for the task (e.g. "fix-bug_ab12"). + // Set when the task uses the multi-repo task-directory layout + // (~/.kandev/tasks/{TaskDirName}/{RepoName}/). + TaskDirName string `json:"task_dir_name,omitempty"` + + // Repos contains one entry per repository associated with this environment. + // Populated by repository getters. Empty for environments created before the + // multi-repo backfill ran with no legacy repository_id. + Repos []*TaskEnvironmentRepo `json:"repos,omitempty"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// RepoFor returns the per-repo environment row for repositoryID, or nil if not present. +func (te *TaskEnvironment) RepoFor(repositoryID string) *TaskEnvironmentRepo { + for _, r := range te.Repos { + if r.RepositoryID == repositoryID { + return r + } + } + return nil +} + +// TaskEnvironmentRepo represents the per-repository state of a task environment. +// One row per repository associated with the task: each carries its own worktree +// reference and any per-repo preparation error. +type TaskEnvironmentRepo struct { + ID string `json:"id"` + TaskEnvironmentID string `json:"task_environment_id"` + RepositoryID string `json:"repository_id"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + Position int `json:"position"` + ErrorMessage string `json:"error_message,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // ToAPI converts internal TaskEnvironment to API map. func (te *TaskEnvironment) ToAPI() map[string]interface{} { result := map[string]interface{}{ @@ -600,9 +639,44 @@ func (te *TaskEnvironment) ToAPI() map[string]interface{} { if te.SandboxID != "" { result["sandbox_id"] = te.SandboxID } + if te.TaskDirName != "" { + result["task_dir_name"] = te.TaskDirName + } + if len(te.Repos) > 0 { + repos := make([]map[string]interface{}, 0, len(te.Repos)) + for _, repo := range te.Repos { + repos = append(repos, repo.ToAPI()) + } + result["repos"] = repos + } return result } +// ToAPI converts internal TaskEnvironmentRepo to API map. +func (r *TaskEnvironmentRepo) ToAPI() map[string]interface{} { + out := map[string]interface{}{ + "id": r.ID, + "task_environment_id": r.TaskEnvironmentID, + "repository_id": r.RepositoryID, + "position": r.Position, + "created_at": r.CreatedAt, + "updated_at": r.UpdatedAt, + } + if r.WorktreeID != "" { + out["worktree_id"] = r.WorktreeID + } + if r.WorktreePath != "" { + out["worktree_path"] = r.WorktreePath + } + if r.WorktreeBranch != "" { + out["worktree_branch"] = r.WorktreeBranch + } + if r.ErrorMessage != "" { + out["error_message"] = r.ErrorMessage + } + return out +} + // TaskPlan represents a plan associated with a task type TaskPlan struct { ID string `json:"id"` diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index d2a5fac960..30d4afd73f 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -181,7 +181,8 @@ type EnvironmentRepository interface { ListEnvironments(ctx context.Context) ([]*models.Environment, error) } -// TaskEnvironmentRepository handles per-task execution environment instances. +// TaskEnvironmentRepository handles per-task execution environment instances +// and their per-repository child rows. type TaskEnvironmentRepository interface { CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) @@ -189,6 +190,11 @@ type TaskEnvironmentRepository interface { UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error DeleteTaskEnvironment(ctx context.Context, id string) error DeleteTaskEnvironmentsByTask(ctx context.Context, taskID string) error + CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error + ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) + UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error + DeleteTaskEnvironmentRepo(ctx context.Context, id string) error + DeleteTaskEnvironmentReposByEnv(ctx context.Context, envID string) error } // ReviewRepository handles session file review records. diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index e422e48239..0d5a443228 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -109,6 +109,9 @@ func (r *Repository) initSchema() error { if err := r.backfillTaskEnvironments(); err != nil { return err } + if err := r.backfillTaskEnvironmentRepos(); err != nil { + return err + } if err := r.healTaskEnvironmentWorkspacePaths(); err != nil { return err } @@ -172,6 +175,8 @@ func (r *Repository) runMigrations() error { _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) // Add agent_profile_id column to workflows for per-workflow agent profile override (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`) + // Add task_dir_name column to task_environments for multi-repo task root layout (ignore error if already exists) + _, _ = r.db.Exec(`ALTER TABLE task_environments ADD COLUMN task_dir_name 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`) return nil @@ -608,6 +613,67 @@ func (r *Repository) backfillSingleTask(tx *sql.Tx, row backfillRow) error { return nil } +// backfillTaskEnvironmentRepos populates task_environment_repos from the legacy +// single-repo fields on task_environments. One row per environment that has a +// non-empty repository_id and no existing task_environment_repos row. +// Idempotent. +func (r *Repository) backfillTaskEnvironmentRepos() error { + rows, err := r.db.Query(` + SELECT te.id, + te.repository_id, + COALESCE(te.worktree_id, ''), + COALESCE(te.worktree_path, ''), + COALESCE(te.worktree_branch, ''), + te.created_at + FROM task_environments te + LEFT JOIN task_environment_repos ter ON ter.task_environment_id = te.id + WHERE te.repository_id != '' AND ter.id IS NULL + `) + if err != nil { + return fmt.Errorf("backfill repos: query: %w", err) + } + defer func() { _ = rows.Close() }() + + type envRepoRow struct { + envID, repoID, wtID, wtPath, wtBranch, createdAt string + } + var pending []envRepoRow + for rows.Next() { + var row envRepoRow + if err := rows.Scan(&row.envID, &row.repoID, &row.wtID, &row.wtPath, &row.wtBranch, &row.createdAt); err != nil { + return fmt.Errorf("backfill repos: scan: %w", err) + } + pending = append(pending, row) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("backfill repos: rows: %w", err) + } + if len(pending) == 0 { + return nil + } + + tx, err := r.db.Begin() + if err != nil { + return fmt.Errorf("backfill repos: begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + for _, row := range pending { + if _, err := tx.Exec(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, + worktree_id, worktree_path, worktree_branch, + position, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?) + `, uuid.New().String(), row.envID, row.repoID, + row.wtID, row.wtPath, row.wtBranch, + row.createdAt, row.createdAt); err != nil { + return fmt.Errorf("backfill repos: insert env %s: %w", row.envID, err) + } + } + return tx.Commit() +} + func (r *Repository) initCoreSchema() error { if err := r.initInfraSchema(); err != nil { return err @@ -987,6 +1053,7 @@ func (r *Repository) initSessionWorktreeSchema() error { workspace_path TEXT DEFAULT '', container_id TEXT DEFAULT '', sandbox_id TEXT DEFAULT '', + task_dir_name TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE @@ -995,6 +1062,24 @@ func (r *Repository) initSessionWorktreeSchema() error { CREATE INDEX IF NOT EXISTS idx_task_environments_task_id ON task_environments(task_id); CREATE INDEX IF NOT EXISTS idx_task_environments_status ON task_environments(status); + CREATE TABLE IF NOT EXISTS task_environment_repos ( + id TEXT PRIMARY KEY, + task_environment_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + position INTEGER DEFAULT 0, + error_message TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE, + UNIQUE(task_environment_id, repository_id) + ); + + CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id); + CREATE INDEX IF NOT EXISTS idx_task_environment_repos_repository_id ON task_environment_repos(repository_id); + CREATE TABLE IF NOT EXISTS task_session_worktrees ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, diff --git a/apps/backend/internal/task/repository/sqlite/task_environment.go b/apps/backend/internal/task/repository/sqlite/task_environment.go index 4326a61df6..724c0354ef 100644 --- a/apps/backend/internal/task/repository/sqlite/task_environment.go +++ b/apps/backend/internal/task/repository/sqlite/task_environment.go @@ -12,6 +12,7 @@ import ( ) // CreateTaskEnvironment creates a new task environment record. +// If env.Repos is non-empty, the per-repo rows are inserted in the same transaction. func (r *Repository) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { if env.ID == "" { env.ID = uuid.New().String() @@ -27,25 +28,41 @@ func (r *Repository) CreateTaskEnvironment(ctx context.Context, env *models.Task env.CreatedAt = now env.UpdatedAt = now - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_environments ( id, task_id, repository_id, executor_type, executor_id, executor_profile_id, agent_execution_id, control_port, status, worktree_id, worktree_path, worktree_branch, workspace_path, - container_id, sandbox_id, + container_id, sandbox_id, task_dir_name, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), env.ID, env.TaskID, env.RepositoryID, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.AgentExecutionID, env.ControlPort, string(env.Status), env.WorktreeID, env.WorktreePath, env.WorktreeBranch, env.WorkspacePath, - env.ContainerID, env.SandboxID, + env.ContainerID, env.SandboxID, env.TaskDirName, env.CreatedAt, env.UpdatedAt, - ) - return err + ); err != nil { + return err + } + + for _, repo := range env.Repos { + repo.TaskEnvironmentID = env.ID + if err := r.insertTaskEnvironmentRepoTx(ctx, tx, repo); err != nil { + return err + } + } + + return tx.Commit() } -// GetTaskEnvironment retrieves a task environment by ID. +// GetTaskEnvironment retrieves a task environment by ID, including per-repo rows. func (r *Repository) GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) { env := &models.TaskEnvironment{} var status string @@ -54,14 +71,14 @@ func (r *Repository) GetTaskEnvironment(ctx context.Context, id string) (*models SELECT id, task_id, repository_id, executor_type, executor_id, executor_profile_id, agent_execution_id, control_port, status, worktree_id, worktree_path, worktree_branch, workspace_path, - container_id, sandbox_id, + container_id, sandbox_id, COALESCE(task_dir_name, ''), created_at, updated_at FROM task_environments WHERE id = ? `), id).Scan( &env.ID, &env.TaskID, &env.RepositoryID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, &env.AgentExecutionID, &env.ControlPort, &status, &env.WorktreeID, &env.WorktreePath, &env.WorktreeBranch, &env.WorkspacePath, - &env.ContainerID, &env.SandboxID, + &env.ContainerID, &env.SandboxID, &env.TaskDirName, &env.CreatedAt, &env.UpdatedAt, ) if err == sql.ErrNoRows { @@ -71,10 +88,17 @@ func (r *Repository) GetTaskEnvironment(ctx context.Context, id string) (*models return nil, err } env.Status = models.TaskEnvironmentStatus(status) + + repos, err := r.ListTaskEnvironmentRepos(ctx, env.ID) + if err != nil { + return nil, err + } + env.Repos = repos return env, nil } -// GetTaskEnvironmentByTaskID retrieves the active task environment for a task. +// GetTaskEnvironmentByTaskID retrieves the active task environment for a task, +// including per-repo rows. func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { env := &models.TaskEnvironment{} var status string @@ -83,14 +107,14 @@ func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID stri SELECT id, task_id, repository_id, executor_type, executor_id, executor_profile_id, agent_execution_id, control_port, status, worktree_id, worktree_path, worktree_branch, workspace_path, - container_id, sandbox_id, + container_id, sandbox_id, COALESCE(task_dir_name, ''), created_at, updated_at FROM task_environments WHERE task_id = ? ORDER BY created_at DESC LIMIT 1 `), taskID).Scan( &env.ID, &env.TaskID, &env.RepositoryID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, &env.AgentExecutionID, &env.ControlPort, &status, &env.WorktreeID, &env.WorktreePath, &env.WorktreeBranch, &env.WorkspacePath, - &env.ContainerID, &env.SandboxID, + &env.ContainerID, &env.SandboxID, &env.TaskDirName, &env.CreatedAt, &env.UpdatedAt, ) if err == sql.ErrNoRows { @@ -100,10 +124,17 @@ func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID stri return nil, err } env.Status = models.TaskEnvironmentStatus(status) + + repos, err := r.ListTaskEnvironmentRepos(ctx, env.ID) + if err != nil { + return nil, err + } + env.Repos = repos return env, nil } // UpdateTaskEnvironment updates an existing task environment. +// Per-repo rows are not touched; use the TaskEnvironmentRepo CRUD methods. func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { // Refuse to clear workspace_path on a worktree-mode env. Same rationale // as CreateTaskEnvironment: empty workspace_path produces permanent 503 @@ -118,14 +149,14 @@ func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.Task repository_id = ?, executor_type = ?, executor_id = ?, executor_profile_id = ?, agent_execution_id = ?, control_port = ?, status = ?, worktree_id = ?, worktree_path = ?, worktree_branch = ?, workspace_path = ?, - container_id = ?, sandbox_id = ?, + container_id = ?, sandbox_id = ?, task_dir_name = ?, updated_at = ? WHERE id = ? `), env.RepositoryID, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.AgentExecutionID, env.ControlPort, string(env.Status), env.WorktreeID, env.WorktreePath, env.WorktreeBranch, env.WorkspacePath, - env.ContainerID, env.SandboxID, + env.ContainerID, env.SandboxID, env.TaskDirName, env.UpdatedAt, env.ID, ) @@ -140,6 +171,7 @@ func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.Task } // DeleteTaskEnvironment deletes a task environment by ID. +// Per-repo rows are removed via ON DELETE CASCADE. func (r *Repository) DeleteTaskEnvironment(ctx context.Context, id string) error { result, err := r.db.ExecContext(ctx, r.db.Rebind(` DELETE FROM task_environments WHERE id = ? @@ -161,3 +193,135 @@ func (r *Repository) DeleteTaskEnvironmentsByTask(ctx context.Context, taskID st `), taskID) return err } + +// CreateTaskEnvironmentRepo inserts a single per-repo environment row. +func (r *Repository) CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { + if repo.TaskEnvironmentID == "" { + return fmt.Errorf("task_environment_id is required") + } + if repo.RepositoryID == "" { + return fmt.Errorf("repository_id is required") + } + if repo.ID == "" { + repo.ID = uuid.New().String() + } + now := time.Now().UTC() + repo.CreatedAt = now + repo.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, + worktree_id, worktree_path, worktree_branch, + position, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, + repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, + repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt, + ) + return err +} + +// insertTaskEnvironmentRepoTx is the in-transaction variant of CreateTaskEnvironmentRepo. +func (r *Repository) insertTaskEnvironmentRepoTx(ctx context.Context, tx *sql.Tx, repo *models.TaskEnvironmentRepo) error { + if repo.RepositoryID == "" { + return fmt.Errorf("repository_id is required") + } + if repo.ID == "" { + repo.ID = uuid.New().String() + } + now := time.Now().UTC() + repo.CreatedAt = now + repo.UpdatedAt = now + + _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, + worktree_id, worktree_path, worktree_branch, + position, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, + repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, + repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt, + ) + return err +} + +// ListTaskEnvironmentRepos returns all per-repo rows for an environment, ordered by position. +func (r *Repository) ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT id, task_environment_id, repository_id, + worktree_id, worktree_path, worktree_branch, + position, error_message, created_at, updated_at + FROM task_environment_repos + WHERE task_environment_id = ? + ORDER BY position ASC, created_at ASC + `), envID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var out []*models.TaskEnvironmentRepo + for rows.Next() { + repo := &models.TaskEnvironmentRepo{} + if err := rows.Scan( + &repo.ID, &repo.TaskEnvironmentID, &repo.RepositoryID, + &repo.WorktreeID, &repo.WorktreePath, &repo.WorktreeBranch, + &repo.Position, &repo.ErrorMessage, &repo.CreatedAt, &repo.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, repo) + } + return out, rows.Err() +} + +// UpdateTaskEnvironmentRepo updates an existing per-repo row. +func (r *Repository) UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { + repo.UpdatedAt = time.Now().UTC() + + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_environment_repos SET + worktree_id = ?, worktree_path = ?, worktree_branch = ?, + position = ?, error_message = ?, updated_at = ? + WHERE id = ? + `), + repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, + repo.Position, repo.ErrorMessage, repo.UpdatedAt, + repo.ID, + ) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task environment repo not found: %s", repo.ID) + } + return nil +} + +// DeleteTaskEnvironmentRepo removes a single per-repo row. +func (r *Repository) DeleteTaskEnvironmentRepo(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_environment_repos WHERE id = ? + `), id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task environment repo not found: %s", id) + } + return nil +} + +// DeleteTaskEnvironmentReposByEnv removes all per-repo rows for an environment. +func (r *Repository) DeleteTaskEnvironmentReposByEnv(ctx context.Context, envID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_environment_repos WHERE task_environment_id = ? + `), envID) + return err +} diff --git a/apps/backend/internal/task/repository/task_environment_repo_test.go b/apps/backend/internal/task/repository/task_environment_repo_test.go new file mode 100644 index 0000000000..06f5b43310 --- /dev/null +++ b/apps/backend/internal/task/repository/task_environment_repo_test.go @@ -0,0 +1,289 @@ +package repository + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/worktree" +) + +// Phase 0 tests for the multi-repo TaskEnvironment / TaskEnvironmentRepo schema. + +func newEnv(t *testing.T, repo *sqlite.Repository, taskID, envID string) *models.TaskEnvironment { + t.Helper() + env := &models.TaskEnvironment{ + ID: envID, + TaskID: taskID, + ExecutorType: "local_pc", + Status: models.TaskEnvironmentStatusReady, + TaskDirName: "fix-bug_ab12", + } + if err := repo.CreateTaskEnvironment(context.Background(), env); err != nil { + t.Fatalf("create env: %v", err) + } + return env +} + +func newTaskWithRepo(t *testing.T, repo *sqlite.Repository, taskID string) { + t.Helper() + ctx := context.Background() + wf := &models.Workflow{ID: "wf-env", Name: "Env Workflow"} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ID: taskID, WorkflowID: wf.ID, Title: "Env Task"} + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("create task: %v", err) + } +} + +func TestTaskEnvironment_TaskDirNameRoundTrip(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + + newTaskWithRepo(t, repo, "task-env-1") + env := newEnv(t, repo, "task-env-1", "env-1") + + got, err := repo.GetTaskEnvironment(context.Background(), env.ID) + if err != nil { + t.Fatalf("get env: %v", err) + } + if got.TaskDirName != "fix-bug_ab12" { + t.Errorf("expected task_dir_name persisted; got %q", got.TaskDirName) + } + if len(got.Repos) != 0 { + t.Errorf("expected 0 repos, got %d", len(got.Repos)) + } +} + +func TestTaskEnvironmentRepo_CRUD(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + newTaskWithRepo(t, repo, "task-env-2") + env := newEnv(t, repo, "task-env-2", "env-2") + + er := &models.TaskEnvironmentRepo{ + TaskEnvironmentID: env.ID, + RepositoryID: "repo-frontend", + WorktreeID: "wt-1", + WorktreePath: "/tmp/tasks/x/frontend", + WorktreeBranch: "feature/x", + Position: 0, + } + if err := repo.CreateTaskEnvironmentRepo(ctx, er); err != nil { + t.Fatalf("create env repo: %v", err) + } + if er.ID == "" { + t.Fatal("expected ID to be assigned") + } + + list, err := repo.ListTaskEnvironmentRepos(ctx, env.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 1 || list[0].RepositoryID != "repo-frontend" { + t.Fatalf("unexpected list: %+v", list) + } + + er.WorktreeBranch = "feature/x-renamed" + er.ErrorMessage = "fetch failed" + if err := repo.UpdateTaskEnvironmentRepo(ctx, er); err != nil { + t.Fatalf("update: %v", err) + } + list, _ = repo.ListTaskEnvironmentRepos(ctx, env.ID) + if list[0].WorktreeBranch != "feature/x-renamed" || list[0].ErrorMessage != "fetch failed" { + t.Errorf("update did not persist: %+v", list[0]) + } + + if err := repo.DeleteTaskEnvironmentRepo(ctx, er.ID); err != nil { + t.Fatalf("delete: %v", err) + } + list, _ = repo.ListTaskEnvironmentRepos(ctx, env.ID) + if len(list) != 0 { + t.Errorf("expected empty list after delete, got %d", len(list)) + } +} + +func TestTaskEnvironmentRepo_PreloadOnGet(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + newTaskWithRepo(t, repo, "task-env-3") + env := newEnv(t, repo, "task-env-3", "env-3") + + rows := []*models.TaskEnvironmentRepo{ + {TaskEnvironmentID: env.ID, RepositoryID: "repo-backend", Position: 1, WorktreePath: "/b"}, + {TaskEnvironmentID: env.ID, RepositoryID: "repo-frontend", Position: 0, WorktreePath: "/f"}, + } + for _, r := range rows { + if err := repo.CreateTaskEnvironmentRepo(ctx, r); err != nil { + t.Fatalf("create: %v", err) + } + } + + got, err := repo.GetTaskEnvironment(ctx, env.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if len(got.Repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(got.Repos)) + } + if got.Repos[0].RepositoryID != "repo-frontend" { + t.Errorf("expected position-ordered list, frontend (pos=0) first; got %s", got.Repos[0].RepositoryID) + } + if got.RepoFor("repo-backend") == nil { + t.Error("expected RepoFor lookup to find repo-backend") + } + if got.RepoFor("missing") != nil { + t.Error("expected RepoFor to return nil for unknown repo") + } +} + +func TestTaskEnvironmentRepo_CreateWithEmbeddedRepos(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + newTaskWithRepo(t, repo, "task-env-4") + env := &models.TaskEnvironment{ + ID: "env-4", + TaskID: "task-env-4", + ExecutorType: "local_pc", + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-a", Position: 0, WorktreePath: "/a"}, + {RepositoryID: "repo-b", Position: 1, WorktreePath: "/b"}, + }, + } + if err := repo.CreateTaskEnvironment(ctx, env); err != nil { + t.Fatalf("create env with repos: %v", err) + } + got, err := repo.GetTaskEnvironment(ctx, env.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if len(got.Repos) != 2 { + t.Fatalf("expected 2 repos persisted, got %d", len(got.Repos)) + } + for _, r := range got.Repos { + if r.TaskEnvironmentID != env.ID { + t.Errorf("expected env id wired; got %q", r.TaskEnvironmentID) + } + } +} + +func TestTaskEnvironmentRepo_CascadeDeleteOnEnvDelete(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + newTaskWithRepo(t, repo, "task-env-5") + env := newEnv(t, repo, "task-env-5", "env-5") + er := &models.TaskEnvironmentRepo{TaskEnvironmentID: env.ID, RepositoryID: "repo-x"} + if err := repo.CreateTaskEnvironmentRepo(ctx, er); err != nil { + t.Fatalf("create: %v", err) + } + if err := repo.DeleteTaskEnvironment(ctx, env.ID); err != nil { + t.Fatalf("delete env: %v", err) + } + list, err := repo.ListTaskEnvironmentRepos(ctx, env.ID) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if len(list) != 0 { + t.Errorf("expected cascade delete to remove per-repo rows, got %d", len(list)) + } +} + +func TestTaskEnvironmentRepo_BackfillFromLegacyEnv(t *testing.T) { + // Backfill is run by initSchema. Simulate a "legacy" environment by inserting + // directly into task_environments with repository_id set, then re-opening the + // repository so initSchema re-runs the backfill on the existing data. + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "backfill.db") + + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := sqlite.NewWithDB(sqlxDB, sqlxDB) + if err != nil { + t.Fatalf("new repo: %v", err) + } + if _, err := worktree.NewSQLiteStore(sqlxDB, sqlxDB); err != nil { + t.Fatalf("worktree store: %v", err) + } + + ctx := context.Background() + newTaskWithRepo(t, repo, "task-env-6") + + // Insert a legacy-shaped env (no per-repo row exists). + if _, err := sqlxDB.Exec(` + INSERT INTO task_environments ( + id, task_id, repository_id, executor_type, executor_id, executor_profile_id, + agent_execution_id, control_port, status, + worktree_id, worktree_path, worktree_branch, workspace_path, + container_id, sandbox_id, task_dir_name, + created_at, updated_at + ) VALUES ('legacy-env', 'task-env-6', 'repo-legacy', 'local_pc', '', '', + '', 0, 'ready', + 'wt-legacy', '/wt/legacy', 'main', '', + '', '', '', + datetime('now'), datetime('now')) + `); err != nil { + t.Fatalf("insert legacy env: %v", err) + } + + // Verify no per-repo row yet. + list, err := repo.ListTaskEnvironmentRepos(ctx, "legacy-env") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 0 { + t.Fatalf("expected 0 per-repo rows pre-backfill, got %d", len(list)) + } + + // Re-open the repository to retrigger initSchema → backfill. + if err := repo.Close(); err != nil { + t.Fatalf("close: %v", err) + } + dbConn2, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("reopen db: %v", err) + } + sqlxDB2 := sqlx.NewDb(dbConn2, "sqlite3") + repo2, err := sqlite.NewWithDB(sqlxDB2, sqlxDB2) + if err != nil { + t.Fatalf("reopen repo: %v", err) + } + defer func() { + _ = repo2.Close() + _ = sqlxDB2.Close() + }() + + list, err = repo2.ListTaskEnvironmentRepos(ctx, "legacy-env") + if err != nil { + t.Fatalf("list after reopen: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected 1 backfilled row, got %d", len(list)) + } + got := list[0] + if got.RepositoryID != "repo-legacy" || got.WorktreeID != "wt-legacy" || got.WorktreeBranch != "main" { + t.Errorf("backfilled row mismatch: %+v", got) + } + + // Idempotency: a second backfill must not duplicate rows. + list, _ = repo2.ListTaskEnvironmentRepos(ctx, "legacy-env") + if len(list) != 1 { + t.Errorf("expected backfill to be idempotent, got %d rows", len(list)) + } +} diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index 313bb37334..83679fd6cf 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -136,37 +136,62 @@ func (s *Service) ValidateLocalRepositoryPath(ctx context.Context, path string) }, nil } -func (s *Service) ListRepositoryBranches(ctx context.Context, repoID string) ([]Branch, error) { - repo, err := s.repoEntities.GetRepository(ctx, repoID) - if err != nil { - return nil, err - } - if repo.LocalPath == "" { - return nil, fmt.Errorf("repository local path is empty") - } - absPath, err := filepath.Abs(repo.LocalPath) - if err != nil { - return nil, fmt.Errorf("invalid repository path: %w", err) - } - if !isPathAllowed(absPath, s.discoveryRoots()) { - return nil, ErrPathNotAllowed - } - info, err := os.Stat(absPath) - if err != nil { - return nil, err - } - if !info.IsDir() { - return nil, fmt.Errorf("repository path is not a directory") - } - return listGitBranches(absPath) +// BranchListResult bundles a branch list with the currently-checked-out +// branch so the unified branches endpoint can answer both in one call. +type BranchListResult struct { + Branches []Branch + CurrentBranch string } -func (s *Service) ListLocalRepositoryBranches(ctx context.Context, path string) ([]Branch, error) { - absPath, err := s.resolveAllowedLocalPath(path) +// ListBranches lists git branches for either an imported workspace repo +// (by id, path resolved from the DB row) or an on-machine folder (by path +// directly). Exactly one of `repoID` or `path` should be set; the caller +// (the HTTP handler) validates that. +func (s *Service) ListBranches(ctx context.Context, repoID, path string) ([]Branch, error) { + resolved, err := s.resolveBranchListingPath(ctx, repoID, path) if err != nil { return nil, err } - return listGitBranches(absPath) + return listGitBranches(resolved) +} + +// ListBranchesWithCurrent is ListBranches plus the current-branch readout. +// One method so the handler resolves the path once instead of twice. +func (s *Service) ListBranchesWithCurrent(ctx context.Context, repoID, path string) (BranchListResult, error) { + resolved, err := s.resolveBranchListingPath(ctx, repoID, path) + if err != nil { + return BranchListResult{}, err + } + branches, err := listGitBranches(resolved) + if err != nil { + return BranchListResult{}, err + } + // Current branch is best-effort metadata; an empty string is fine if + // HEAD is detached or unreadable. + return BranchListResult{ + Branches: branches, + CurrentBranch: readGitCurrentBranch(resolved, s.discoveryRoots()), + }, nil +} + +// resolveBranchListingPath turns the request inputs into a validated +// absolute path to list branches in. Both inputs go through the same +// allowed-roots / dir / symlink validation via resolveAllowedLocalPath. +func (s *Service) resolveBranchListingPath(ctx context.Context, repoID, path string) (string, error) { + switch { + case repoID != "": + repo, err := s.repoEntities.GetRepository(ctx, repoID) + if err != nil { + return "", err + } + if repo.LocalPath == "" { + return "", fmt.Errorf("repository local path is empty") + } + path = repo.LocalPath + case path == "": + return "", fmt.Errorf("repository_id or path is required") + } + return s.resolveAllowedLocalPath(path) } // LocalRepositoryCurrentBranch returns the currently checked-out branch for a diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 7eb95eb6c5..768741a0b0 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -50,11 +50,14 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * data["archived_at"] = task.ArchivedAt.Format(time.RFC3339) } // Orchestrator-originated events fetch the task via the raw repo.GetTask, - // which does not populate Repositories. Load the primary on demand so the - // payload always carries repository_id — matching the HTTP DTO and - // preventing the frontend from losing repositoryId on WS updates. - if repoID := primaryRepositoryID(ctx, s, task); repoID != "" { - data["repository_id"] = repoID + // which does not populate Repositories. Load on demand so the payload + // carries the full per-task repository list — matching the HTTP DTO and + // preventing the frontend from collapsing multi-repo tasks down to the + // primary repo on WS updates. + repos := taskRepositoriesForEvent(ctx, s, task) + if len(repos) > 0 { + data["repository_id"] = repos[0].RepositoryID + data["repositories"] = serializeTaskRepositories(repos) } if task.Metadata != nil { data["metadata"] = task.Metadata @@ -116,19 +119,37 @@ func (s *Service) addTaskSessionEventFields(ctx context.Context, taskID string, } } -// primaryRepositoryID returns the primary repository_id for the task. Prefers -// the already-loaded Task.Repositories slice; falls back to a lookup so -// publishers that pass a task without eagerly loaded repositories (e.g. the -// orchestrator's raw repo.GetTask) still emit repository_id. -func primaryRepositoryID(ctx context.Context, s *Service, task *models.Task) string { +// taskRepositoriesForEvent returns the task's full repository list, ordered by +// position. Prefers Task.Repositories when already loaded; falls back to a +// lookup so publishers that pass a task without eagerly loaded repositories +// (e.g. the orchestrator's raw repo.GetTask) still emit per-repo data. +func taskRepositoriesForEvent(ctx context.Context, s *Service, task *models.Task) []*models.TaskRepository { if len(task.Repositories) > 0 { - return task.Repositories[0].RepositoryID + return task.Repositories } - repo, err := s.taskRepos.GetPrimaryTaskRepository(ctx, task.ID) - if err != nil || repo == nil { - return "" + repos, err := s.taskRepos.ListTaskRepositories(ctx, task.ID) + if err != nil { + return nil } - return repo.RepositoryID + return repos +} + +// serializeTaskRepositories returns the WS-shaped repositories array. Mirrors +// the HTTP DTO's TaskRepositoryDTO so the frontend can use the same parser +// across SSR snapshot and WS update paths. +func serializeTaskRepositories(repos []*models.TaskRepository) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(repos)) + for _, r := range repos { + out = append(out, map[string]interface{}{ + "id": r.ID, + "task_id": r.TaskID, + "repository_id": r.RepositoryID, + "base_branch": r.BaseBranch, + "checkout_branch": r.CheckoutBranch, + "position": r.Position, + }) + } + return out } // publishTaskMovedEvent publishes a task.moved event so the orchestrator can process diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 549b040273..66228cea40 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -120,6 +120,7 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI } } + seen := make(map[string]bool, len(repositories)) for i, repoInput := range repositories { repositoryID, baseBranch, err := s.resolveRepoInput(ctx, workspaceID, repoInput, repoByPath) if err != nil { @@ -128,6 +129,14 @@ func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceI if repositoryID == "" { return fmt.Errorf("repository_id is required") } + // Multi-repo validation: each repository may appear at most once per task. + // Without this guard the unique (task_id, repository_id) constraint on + // task_repositories would surface as an opaque DB error mid-loop, leaving + // some rows already inserted. + if seen[repositoryID] { + return fmt.Errorf("repository %q listed more than once for the task", repositoryID) + } + seen[repositoryID] = true taskRepo := &models.TaskRepository{ TaskID: taskID, RepositoryID: repositoryID, diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 36bb30297e..7dd444e1dd 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -168,6 +168,58 @@ func TestService_CreateTask(t *testing.T) { } } +func TestService_CreateTask_RejectsDuplicateRepositories(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}) + _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-dup", WorkspaceID: "ws-1", Name: "Dup"}) + + req := &CreateTaskRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + Title: "dup repos", + Repositories: []TaskRepositoryInput{ + {RepositoryID: "repo-dup", BaseBranch: "main"}, + {RepositoryID: "repo-dup", BaseBranch: "main"}, + }, + } + _, err := svc.CreateTask(ctx, req) + if err == nil { + t.Fatal("expected duplicate-repository error, got nil") + } +} + +func TestService_CreateTask_AcceptsMultipleDistinctRepositories(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}) + _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-front", WorkspaceID: "ws-1", Name: "front"}) + _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-back", WorkspaceID: "ws-1", Name: "back"}) + + req := &CreateTaskRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + Title: "multi", + Repositories: []TaskRepositoryInput{ + {RepositoryID: "repo-front", BaseBranch: "main"}, + {RepositoryID: "repo-back", BaseBranch: "main"}, + }, + } + task, err := svc.CreateTask(ctx, req) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + if len(task.Repositories) != 2 { + t.Errorf("expected 2 task repositories, got %d", len(task.Repositories)) + } +} + func TestService_CreateRepository_DefaultWorktreeBranchPrefix(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 5cca5e15c7..07e4d595e7 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "path/filepath" "sort" "time" @@ -215,9 +216,16 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio taskID = session.TaskID } - // Get workspace path from the session's worktree + // Get workspace path from the session's worktree(s). + // Multi-repo: every per-repo worktree sits as a sibling under the task root + // (~/.kandev/tasks/{taskDirName}/{repoName}/), so the workspace path agentctl + // needs is the parent of any one of them. Picking the first repo's path here + // would point agentctl at a single repo subdir and disable the per-repo + // tracker fan-out that scanRepositorySubdirs relies on. var workspacePath string - if len(session.Worktrees) > 0 { + if len(session.Worktrees) > 1 { + workspacePath = filepath.Dir(session.Worktrees[0].WorktreePath) + } else if len(session.Worktrees) == 1 { workspacePath = session.Worktrees[0].WorktreePath } diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index 05d1a4e150..f91cdb0e70 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "testing" "time" @@ -266,3 +267,52 @@ func TestGetWorkspaceInfoForEnvironment(t *testing.T) { t.Errorf("TaskEnvironmentID = %q, want env-123", info.TaskEnvironmentID) } } + +// Multi-repo: the workspace path agentctl boots with must be the task root +// (parent of every per-repo subdir) so its scanRepositorySubdirs detects all +// repos and starts a per-repo tracker for each. Returning a single repo's +// path would collapse fan-out into the legacy single-tracker mode and +// suppress the per-repo events the Changes panel needs to render headers. +func TestGetWorkspaceInfoForSession_MultiRepoReturnsTaskRoot(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + + session := &models.TaskSession{ + ID: "session-multi", + TaskID: "task-123", + State: models.TaskSessionStateCompleted, + StartedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("create session: %v", err) + } + for i, path := range []string{ + "/tmp/tasks/do-nothing_mvo/kandev", + "/tmp/tasks/do-nothing_mvo/thm", + } { + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: fmt.Sprintf("wt%d", i), + SessionID: session.ID, + WorktreeID: fmt.Sprintf("wid%d", i), + RepositoryID: fmt.Sprintf("repo%d", i), + Position: i, + WorktreePath: path, + CreatedAt: now, + }); err != nil { + t.Fatalf("create worktree %d: %v", i, err) + } + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", session.ID) + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession: %v", err) + } + if info.WorkspacePath != "/tmp/tasks/do-nothing_mvo" { + t.Errorf("expected WorkspacePath '/tmp/tasks/do-nothing_mvo' (task root), got %q", + info.WorkspacePath) + } +} diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go index fe11ad4b6d..b31778eb40 100644 --- a/apps/backend/internal/worktree/manager.go +++ b/apps/backend/internal/worktree/manager.go @@ -32,11 +32,15 @@ type repoLockEntry struct { // Manager handles Git worktree operations for concurrent agent execution. type Manager struct { - config Config - logger *logger.Logger - store Store - worktrees map[string]*Worktree // sessionID -> worktree (in-memory cache) - mu sync.RWMutex // Protects worktrees map + config Config + logger *logger.Logger + store Store + // worktrees is the in-memory cache keyed by cacheKey(sessionID, repositoryID). + // For legacy single-repo writes the repositoryID may be empty, in which + // case the cache key collapses to "{sessionID}|" — still distinct from + // any per-repo entry the same session might gain later. + worktrees map[string]*Worktree + mu sync.RWMutex // Protects worktrees map repoLocks map[string]*repoLockEntry repoLockMu sync.Mutex @@ -63,7 +67,10 @@ type Store interface { CreateWorktree(ctx context.Context, wt *Worktree) error // GetWorktreeByID retrieves a worktree by its unique ID. GetWorktreeByID(ctx context.Context, id string) (*Worktree, error) - // GetWorktreeBySessionID retrieves the worktree by session ID. + // GetWorktreeBySessionID retrieves a single active worktree by session ID. + // For multi-repo sessions, returns the first one found (typically the + // primary/first-created). Use GetWorktreesBySessionID when the caller + // needs all of them. GetWorktreeBySessionID(ctx context.Context, sessionID string) (*Worktree, error) // GetWorktreesByTaskID retrieves all worktrees for a task (used for cleanup on task deletion). GetWorktreesByTaskID(ctx context.Context, taskID string) ([]*Worktree, error) @@ -77,6 +84,18 @@ type Store interface { ListActiveWorktrees(ctx context.Context) ([]*Worktree, error) } +// MultiRepoStore is an optional capability some stores implement to support +// multi-repo task sessions (one worktree per repository per session). The +// Manager checks at runtime whether its Store satisfies this interface and +// uses the multi-repo lookups when available. +type MultiRepoStore interface { + // GetWorktreesBySessionID returns all active worktrees for the session. + GetWorktreesBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error) + // GetWorktreeBySessionAndRepository returns the active worktree for the + // given (session, repository) pair, or nil if none exists. + GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID string) (*Worktree, error) +} + // NewManager creates a new worktree manager. func NewManager(cfg Config, store Store, log *logger.Logger) (*Manager, error) { if err := cfg.Validate(); err != nil { @@ -181,14 +200,18 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err return nil, err } - // First, check if a worktree already exists for this session + // First, check if a worktree already exists for this (session, repository) pair. + // Multi-repo sessions can host multiple worktrees concurrently, so we must + // scope the lookup by RepositoryID rather than session alone — otherwise + // the second repo's Create would return the first repo's worktree. if req.SessionID != "" { - existing, err := m.GetBySessionID(ctx, req.SessionID) + existing, err := m.GetBySessionAndRepo(ctx, req.SessionID, req.RepositoryID) if err == nil && existing != nil { if m.IsValid(existing.Path) { - m.logger.Debug("reusing existing worktree by session ID", + m.logger.Debug("reusing existing worktree by session+repo", zap.String("worktree_id", existing.ID), zap.String("session_id", req.SessionID), + zap.String("repository_id", req.RepositoryID), zap.String("task_id", req.TaskID), zap.String("path", existing.Path)) return existing, nil @@ -197,6 +220,7 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err m.logger.Warn("worktree directory invalid, recreating", zap.String("worktree_id", existing.ID), zap.String("session_id", req.SessionID), + zap.String("repository_id", req.RepositoryID), zap.String("task_id", req.TaskID)) return m.recreate(ctx, existing, req) } @@ -732,10 +756,11 @@ func (m *Manager) persistAndCacheWorktree(ctx context.Context, wt *Worktree, req } } - // Update cache (keyed by sessionID) + // Update cache keyed by (sessionID, repositoryID) so multi-repo sessions + // can hold multiple entries. if req.SessionID != "" { m.mu.Lock() - m.worktrees[req.SessionID] = wt + m.worktrees[cacheKey(req.SessionID, req.RepositoryID)] = wt m.mu.Unlock() } return nil @@ -760,11 +785,33 @@ func (m *Manager) persistWorktree(ctx context.Context, wt *Worktree, req CreateR return nil } -// GetBySessionID returns the worktree for a session, if it exists. +// cacheKey computes the cache key for a (sessionID, repositoryID) pair. +// Used by all read/write paths against m.worktrees so multi-repo sessions +// can hold multiple worktrees concurrently. +func cacheKey(sessionID, repositoryID string) string { + return sessionID + "|" + repositoryID +} + +// firstCacheEntryForSession scans the cache for any entry belonging to +// sessionID. Returns the first match or nil. Caller must hold m.mu. +func (m *Manager) firstCacheEntryForSession(sessionID string) *Worktree { + prefix := sessionID + "|" + for k, wt := range m.worktrees { + if strings.HasPrefix(k, prefix) { + return wt + } + } + return nil +} + +// GetBySessionID returns one worktree for the session. For multi-repo sessions +// it returns whichever worktree happens to come first; callers that need a +// specific repo's worktree should use GetBySessionAndRepo, and callers that +// need them all should use GetAllBySessionID. func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktree, error) { - // Check cache first (keyed by sessionID) + // Check cache first m.mu.RLock() - if wt, ok := m.worktrees[sessionID]; ok { + if wt := m.firstCacheEntryForSession(sessionID); wt != nil { m.mu.RUnlock() return wt, nil } @@ -777,9 +824,10 @@ func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktr return nil, err } if wt != nil { - // Update cache + // Update cache under the wt's repository key (or empty for legacy + // records that never had one). m.mu.Lock() - m.worktrees[sessionID] = wt + m.worktrees[cacheKey(sessionID, wt.RepositoryID)] = wt m.mu.Unlock() return wt, nil } @@ -788,6 +836,88 @@ func (m *Manager) GetBySessionID(ctx context.Context, sessionID string) (*Worktr return nil, ErrWorktreeNotFound } +// GetAllBySessionID returns all active worktrees for a session. Empty slice +// (not error) if the session has none. +func (m *Manager) GetAllBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error) { + if m.store == nil { + // Cache-only path: scan our in-memory map. + m.mu.RLock() + defer m.mu.RUnlock() + prefix := sessionID + "|" + var out []*Worktree + for k, wt := range m.worktrees { + if strings.HasPrefix(k, prefix) { + out = append(out, wt) + } + } + return out, nil + } + if multi, ok := m.store.(MultiRepoStore); ok { + wts, err := multi.GetWorktreesBySessionID(ctx, sessionID) + if err != nil { + return nil, err + } + // Refresh cache while we're at it. + m.mu.Lock() + for _, wt := range wts { + m.worktrees[cacheKey(sessionID, wt.RepositoryID)] = wt + } + m.mu.Unlock() + return wts, nil + } + // Single-repo store fallback: at most one worktree per session. + wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID) + if err != nil { + return nil, err + } + if wt == nil { + return nil, nil + } + return []*Worktree{wt}, nil +} + +// GetBySessionAndRepo returns the active worktree for the (session, repo) +// pair, or ErrWorktreeNotFound if none exists. Falls back to GetBySessionID +// when the store does not implement MultiRepoStore (legacy single-repo). +func (m *Manager) GetBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*Worktree, error) { + if sessionID == "" { + return nil, ErrWorktreeNotFound + } + // Check cache first. + m.mu.RLock() + if wt, ok := m.worktrees[cacheKey(sessionID, repositoryID)]; ok { + m.mu.RUnlock() + return wt, nil + } + m.mu.RUnlock() + + if m.store == nil { + return nil, ErrWorktreeNotFound + } + if multi, ok := m.store.(MultiRepoStore); ok { + wt, err := multi.GetWorktreeBySessionAndRepository(ctx, sessionID, repositoryID) + if err != nil { + return nil, err + } + if wt == nil { + return nil, ErrWorktreeNotFound + } + m.mu.Lock() + m.worktrees[cacheKey(sessionID, repositoryID)] = wt + m.mu.Unlock() + return wt, nil + } + // Legacy single-repo store: best we can do is the session lookup. + wt, err := m.store.GetWorktreeBySessionID(ctx, sessionID) + if err != nil { + return nil, err + } + if wt == nil || (repositoryID != "" && wt.RepositoryID != repositoryID) { + return nil, ErrWorktreeNotFound + } + return wt, nil +} + // GetByID returns a worktree by its unique ID. func (m *Manager) GetByID(ctx context.Context, worktreeID string) (*Worktree, error) { if m.store == nil { @@ -900,10 +1030,11 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch } } - // Update cache + // Update cache: delete the (session, repo) entry. Removing a worktree only + // affects its own repo; siblings on other repos must remain cached. m.mu.Lock() if wt.SessionID != "" { - delete(m.worktrees, wt.SessionID) + delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID)) } m.mu.Unlock() @@ -962,7 +1093,7 @@ func (m *Manager) runWorktreeSetupScript(ctx context.Context, wt *Worktree, repo func (m *Manager) cleanupWorktreeOnSetupFailure(ctx context.Context, wt *Worktree, repositoryPath string) { if wt.SessionID != "" { m.mu.Lock() - delete(m.worktrees, wt.SessionID) + delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID)) m.mu.Unlock() } if cleanupErr := m.removeWorktreeDir(ctx, wt.Path, repositoryPath); cleanupErr != nil { @@ -1042,7 +1173,7 @@ func (m *Manager) CleanupWorktrees(ctx context.Context, worktrees []*Worktree) e continue } if wt.SessionID != "" { - delete(m.worktrees, wt.SessionID) + delete(m.worktrees, cacheKey(wt.SessionID, wt.RepositoryID)) } } m.mu.Unlock() @@ -1387,10 +1518,10 @@ func (m *Manager) recreate(ctx context.Context, existing *Worktree, req CreateRe } } - // Update cache (keyed by sessionID) + // Update cache keyed by (sessionID, repositoryID). if req.SessionID != "" { m.mu.Lock() - m.worktrees[req.SessionID] = existing + m.worktrees[cacheKey(req.SessionID, req.RepositoryID)] = existing m.mu.Unlock() } diff --git a/apps/backend/internal/worktree/manager_multi_repo_test.go b/apps/backend/internal/worktree/manager_multi_repo_test.go new file mode 100644 index 0000000000..abd139091e --- /dev/null +++ b/apps/backend/internal/worktree/manager_multi_repo_test.go @@ -0,0 +1,115 @@ +package worktree + +import ( + "context" + "testing" + "time" +) + +// helpers --------------------------------------------------------------------- + +func newMultiRepoTestManager(t *testing.T) (*Manager, *mockStore) { + t.Helper() + cfg := newTestConfig(t) + store := newMockStore() + mgr, err := NewManager(cfg, store, newTestLogger()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + return mgr, store +} + +func seedWorktree(store *mockStore, sessionID, repoID, id string) *Worktree { + wt := &Worktree{ + ID: id, + SessionID: sessionID, + RepositoryID: repoID, + TaskID: "task-x", + Path: "/tmp/wt/" + id, + Branch: "feat/" + id, + Status: StatusActive, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + store.worktrees[id] = wt + return wt +} + +// tests ----------------------------------------------------------------------- + +func TestManager_GetAllBySessionID_ReturnsAllRepos(t *testing.T) { + mgr, store := newMultiRepoTestManager(t) + seedWorktree(store, "sess-1", "repo-front", "wt-1") + seedWorktree(store, "sess-1", "repo-back", "wt-2") + seedWorktree(store, "sess-2", "repo-front", "wt-3") // unrelated session + + got, err := mgr.GetAllBySessionID(context.Background(), "sess-1") + if err != nil { + t.Fatalf("GetAllBySessionID: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 worktrees for sess-1, got %d", len(got)) + } + ids := map[string]bool{got[0].ID: true, got[1].ID: true} + if !ids["wt-1"] || !ids["wt-2"] { + t.Errorf("unexpected ids returned: %v", ids) + } +} + +func TestManager_GetBySessionAndRepo_PicksTheRightOne(t *testing.T) { + mgr, store := newMultiRepoTestManager(t) + seedWorktree(store, "sess-1", "repo-front", "wt-1") + seedWorktree(store, "sess-1", "repo-back", "wt-2") + + got, err := mgr.GetBySessionAndRepo(context.Background(), "sess-1", "repo-back") + if err != nil { + t.Fatalf("GetBySessionAndRepo: %v", err) + } + if got.ID != "wt-2" { + t.Errorf("expected wt-2 for repo-back, got %s", got.ID) + } +} + +func TestManager_GetBySessionAndRepo_NotFoundForUnknownRepo(t *testing.T) { + mgr, store := newMultiRepoTestManager(t) + seedWorktree(store, "sess-1", "repo-front", "wt-1") + + _, err := mgr.GetBySessionAndRepo(context.Background(), "sess-1", "nope") + if err == nil { + t.Fatal("expected ErrWorktreeNotFound for unknown repo") + } +} + +func TestManager_GetBySessionID_ReturnsAnyMatch_SingleRepoCompat(t *testing.T) { + mgr, store := newMultiRepoTestManager(t) + seedWorktree(store, "sess-1", "repo-only", "wt-1") + + got, err := mgr.GetBySessionID(context.Background(), "sess-1") + if err != nil { + t.Fatalf("GetBySessionID: %v", err) + } + if got.ID != "wt-1" { + t.Errorf("expected wt-1, got %s", got.ID) + } +} + +func TestManager_CacheKey_SeparatesReposPerSession(t *testing.T) { + // Two cache entries for the same session must not collide. We simulate by + // directly writing to the cache the same way persistAndCacheWorktree does. + mgr, _ := newMultiRepoTestManager(t) + wtA := &Worktree{ID: "wt-A", SessionID: "s", RepositoryID: "rA", Path: "/a", Status: StatusActive} + wtB := &Worktree{ID: "wt-B", SessionID: "s", RepositoryID: "rB", Path: "/b", Status: StatusActive} + mgr.mu.Lock() + mgr.worktrees[cacheKey("s", "rA")] = wtA + mgr.worktrees[cacheKey("s", "rB")] = wtB + mgr.mu.Unlock() + + gotA, err := mgr.GetBySessionAndRepo(context.Background(), "s", "rA") + if err != nil || gotA.ID != "wt-A" { + t.Errorf("expected wt-A from cache; got %v err=%v", gotA, err) + } + gotB, err := mgr.GetBySessionAndRepo(context.Background(), "s", "rB") + if err != nil || gotB.ID != "wt-B" { + t.Errorf("expected wt-B from cache; got %v err=%v", gotB, err) + } +} diff --git a/apps/backend/internal/worktree/manager_test.go b/apps/backend/internal/worktree/manager_test.go index ad9c3d343b..21a8aef010 100644 --- a/apps/backend/internal/worktree/manager_test.go +++ b/apps/backend/internal/worktree/manager_test.go @@ -103,6 +103,27 @@ func (s *mockStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, error return result, nil } +// GetWorktreesBySessionID — MultiRepoStore. +func (s *mockStore) GetWorktreesBySessionID(_ context.Context, sessionID string) ([]*Worktree, error) { + var out []*Worktree + for _, wt := range s.worktrees { + if wt.SessionID == sessionID && wt.Status == StatusActive { + out = append(out, wt) + } + } + return out, nil +} + +// GetWorktreeBySessionAndRepository — MultiRepoStore. +func (s *mockStore) GetWorktreeBySessionAndRepository(_ context.Context, sessionID, repoID string) (*Worktree, error) { + for _, wt := range s.worktrees { + if wt.SessionID == sessionID && wt.RepositoryID == repoID && wt.Status == StatusActive { + return wt, nil + } + } + return nil, nil +} + func TestNewManager(t *testing.T) { cfg := newTestConfig(t) log := newTestLogger() diff --git a/apps/backend/internal/worktree/store.go b/apps/backend/internal/worktree/store.go index 6506ab8ad5..269692aec0 100644 --- a/apps/backend/internal/worktree/store.go +++ b/apps/backend/internal/worktree/store.go @@ -441,5 +441,68 @@ func (s *SQLiteStore) scanWorktrees(rows *sql.Rows) ([]*Worktree, error) { return result, rows.Err() } -// Ensure SQLiteStore implements Store interface. -var _ Store = (*SQLiteStore)(nil) +// GetWorktreesBySessionID returns all active worktrees for the session. +// Implements MultiRepoStore. +func (s *SQLiteStore) GetWorktreesBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error) { + rows, err := s.ro.QueryContext(ctx, s.ro.Rebind(` + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + INNER JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE tsw.session_id = ? AND tsw.status = ? + ORDER BY tsw.position ASC, tsw.created_at ASC + `), sessionID, StatusActive) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return s.scanWorktrees(rows) +} + +// GetWorktreeBySessionAndRepository returns the active worktree for the +// given (session, repository) pair, or nil if none exists. +// Implements MultiRepoStore. +func (s *SQLiteStore) GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID string) (*Worktree, error) { + row := s.ro.QueryRowContext(ctx, s.ro.Rebind(` + SELECT + tsw.worktree_id, + tsw.session_id, + s.task_id, + tsw.repository_id, + r.local_path, + tsw.worktree_path, + tsw.worktree_branch, + s.base_branch, + tsw.status, + tsw.created_at, + tsw.updated_at, + tsw.merged_at, + tsw.deleted_at + FROM task_session_worktrees tsw + INNER JOIN task_sessions s ON tsw.session_id = s.id + LEFT JOIN repositories r ON tsw.repository_id = r.id + WHERE tsw.session_id = ? AND tsw.repository_id = ? AND tsw.status = ? + LIMIT 1 + `), sessionID, repositoryID, StatusActive) + return scanWorktreeRow(row) +} + +// Ensure SQLiteStore implements both Store and MultiRepoStore. +var ( + _ Store = (*SQLiteStore)(nil) + _ MultiRepoStore = (*SQLiteStore)(nil) +) diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index f43614f232..bb902f0264 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -9,7 +9,6 @@ import type { ListWorkflowStepsResponse, RepositoryDiscoveryResponse, ListRepositoriesResponse, - RepositoryBranchesResponse, LocalRepositoryStatusResponse, ListRepositoryScriptsResponse, ListWorkspacesResponse, @@ -165,16 +164,9 @@ export async function listRepositoriesAction( return fetchJson(url); } -export async function listLocalRepositoryBranchesAction( - workspaceId: string, - path: string, -): Promise { - const params = `?path=${encodeURIComponent(path)}`; - return fetchJson( - `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/branches${params}`, - ); -} - +// Path-based branch listing moved to lib/api/domains/workspace-api.ts as a +// unified `listBranches({ repositoryId | path })` after the endpoint unification. +// Local repo status (branch + dirty files) backs the fresh-branch consent flow. export async function getLocalRepositoryStatusAction( workspaceId: string, path: string, diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index 0c70c7cc1e..17da8ce378 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -14,6 +14,7 @@ import { CommandList, } from "@kandev/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; export type ComboboxOption = { value: string; @@ -21,6 +22,10 @@ export type ComboboxOption = { description?: string; keywords?: string[]; renderLabel?: () => React.ReactNode; + /** When true the option renders dimmed and isn't selectable. */ + disabled?: boolean; + /** Tooltip shown on hover when disabled is true. */ + disabledReason?: string; }; // Custom filter compatible with cmdk's `` prop. @@ -67,6 +72,34 @@ function TriggerLabel({ return {selectedOption?.label || placeholder}; } +function DisabledOptionItem({ option }: { option: ComboboxOption }) { + // cmdk's CommandItem swallows pointer events with no native tooltip slot, so + // we wrap the disabled item in a Tooltip trigger. The trigger is a plain + // div; CommandItem still owns keyboard nav and rendering. + const item = ( + +
    + {option.renderLabel ? option.renderLabel() : option.label} +
    +
    + ); + if (!option.disabledReason) return item; + return ( + + +
    {item}
    +
    + {option.disabledReason} +
    + ); +} + function OptionsList({ options, value, @@ -78,25 +111,29 @@ function OptionsList({ }) { return ( - {options.map((option) => ( - onSelect(option.value)} - className="relative pr-7" - > -
    - {option.renderLabel ? option.renderLabel() : option.label} -
    - -
    - ))} + {options.map((option) => + option.disabled ? ( + + ) : ( + onSelect(option.value)} + className="relative pr-7" + > +
    + {option.renderLabel ? option.renderLabel() : option.label} +
    + +
    + ), + )}
    ); } diff --git a/apps/web/components/diff/diff-viewer-resolver.tsx b/apps/web/components/diff/diff-viewer-resolver.tsx index 9089fa0014..4e3a8549b9 100644 --- a/apps/web/components/diff/diff-viewer-resolver.tsx +++ b/apps/web/components/diff/diff-viewer-resolver.tsx @@ -38,6 +38,8 @@ interface DiffViewerResolverProps { expandUnchanged?: boolean; /** Callback when expand-unchanged is toggled (pierre-diffs only) */ onToggleExpandUnchanged?: () => void; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; } export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) { @@ -52,6 +54,7 @@ export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffVi expandUnchanged, onToggleExpandUnchanged, onPreviewMarkdown, + repo, ...rest } = props; /* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx index 19e6cef17a..4bc7e68765 100644 --- a/apps/web/components/diff/diff-viewer.tsx +++ b/apps/web/components/diff/diff-viewer.tsx @@ -43,6 +43,8 @@ interface DiffViewerProps { expandUnchanged?: boolean; /** Callback when expand-unchanged is toggled (controlled mode) */ onToggleExpandUnchanged?: () => void; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; } const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [ @@ -61,6 +63,7 @@ const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [ "baseRef", "expandUnchanged", "onToggleExpandUnchanged", + "repo", ]; const DATA_KEYS: (keyof FileDiffData)[] = ["filePath", "diff", "oldContent", "newContent"]; @@ -116,6 +119,71 @@ function NoDiffPlaceholder({ className }: { className?: string }) { ); } +type WiringArgs = { + data: FileDiffData; + state: ReturnType; + onCommentRun?: (comment: DiffComment) => void; + onOpenFile?: (filePath: string) => void; + onPreviewMarkdown?: (filePath: string) => void; + onRevert?: (filePath: string) => void; + enableComments: boolean; + enableExpansion: boolean; + hideHeader: boolean; + compact: boolean; + wordWrap: boolean; + setWordWrap: React.Dispatch>; + expandUnchanged: boolean; + toggleExpandUnchanged: () => void; + wrapperRef: React.RefObject; +}; + +/** + * Bundles the hover/annotation/options wiring for DiffViewer so the top-level + * component body stays under the 100-line cap. Equivalent to inlining the + * three calls — extracted only for size, no behavior change. + */ +function useDiffViewerWiring(args: WiringArgs) { + const { data, state, onCommentRun, wrapperRef, hideHeader, compact, enableExpansion } = args; + const { onLineEnter, onLineLeave, onButtonEnter, onButtonLeave } = useHunkHover({ + wrapperRef, + changeLineMapRef: state.changeLineMapRef, + hideTimeoutRef: state.hideTimeoutRef, + }); + const renderAnnotation = useAnnotationRenderer({ + handleRevertBlock: state.handleRevertBlock, + onButtonEnter, + onButtonLeave, + handleCommentSubmit: state.handleCommentSubmit, + handleCommentSubmitAndRun: state.handleCommentSubmitAndRun, + handleCommentUpdate: state.handleCommentUpdate, + handleCommentDelete: state.handleCommentDelete, + handleCommentRun: onCommentRun, + setShowCommentForm: state.setShowCommentForm, + setSelectedLines: state.setSelectedLines, + setEditingComment: state.setEditingComment, + }); + const showHeader = !hideHeader && !compact; + const canUseExpansion = useAutoLoadExpansion(enableExpansion, state); + const opts = useDiffOptions({ + filePath: data.filePath, + diff: data.diff, + enableComments: args.enableComments, + showHeader, + wordWrap: args.wordWrap, + setWordWrap: args.setWordWrap, + handleLineSelectionEnd: state.handleLineSelectionEnd, + onLineEnter, + onLineLeave, + onOpenFile: args.onOpenFile, + onPreviewMarkdown: args.onPreviewMarkdown, + onRevert: args.onRevert, + enableExpansion: canUseExpansion, + expandUnchanged: args.expandUnchanged, + onToggleExpandUnchanged: canUseExpansion ? args.toggleExpandUnchanged : undefined, + }); + return { ...opts, renderAnnotation }; +} + export const DiffViewer = memo(function DiffViewer({ data, enableComments = false, @@ -137,6 +205,7 @@ export const DiffViewer = memo(function DiffViewer({ baseRef, expandUnchanged: expandUnchangedProp, onToggleExpandUnchanged: onToggleExpandUnchangedProp, + repo, }: DiffViewerProps) { const [wordWrapLocal, setWordWrap] = useState(false); const wordWrap = wordWrapProp ?? wordWrapLocal; @@ -160,48 +229,27 @@ export const DiffViewer = memo(function DiffViewer({ onRevertBlock, enableExpansion, baseRef, + repo, }); - const { onLineEnter, onLineLeave, onButtonEnter, onButtonLeave } = useHunkHover({ - wrapperRef, - changeLineMapRef: state.changeLineMapRef, - hideTimeoutRef: state.hideTimeoutRef, - }); - - const renderAnnotation = useAnnotationRenderer({ - handleRevertBlock: state.handleRevertBlock, - onButtonEnter, - onButtonLeave, - handleCommentSubmit: state.handleCommentSubmit, - handleCommentSubmitAndRun: state.handleCommentSubmitAndRun, - handleCommentUpdate: state.handleCommentUpdate, - handleCommentDelete: state.handleCommentDelete, - handleCommentRun: onCommentRun, - setShowCommentForm: state.setShowCommentForm, - setSelectedLines: state.setSelectedLines, - setEditingComment: state.setEditingComment, - }); - - const showHeader = !hideHeader && !compact; - const canUseExpansion = useAutoLoadExpansion(enableExpansion, state); - - const { options, renderHeaderMetadata, renderHoverUtility } = useDiffOptions({ - filePath: data.filePath, - diff: data.diff, - enableComments, - showHeader, - wordWrap, - setWordWrap, - handleLineSelectionEnd: state.handleLineSelectionEnd, - onLineEnter, - onLineLeave, - onOpenFile, - onPreviewMarkdown, - onRevert, - enableExpansion: canUseExpansion, - expandUnchanged, - onToggleExpandUnchanged: canUseExpansion ? toggleExpandUnchanged : undefined, - }); + const { options, renderHeaderMetadata, renderHoverUtility, renderAnnotation } = + useDiffViewerWiring({ + data, + state, + onCommentRun, + onOpenFile, + onPreviewMarkdown, + onRevert, + enableComments, + enableExpansion, + hideHeader, + compact, + wordWrap, + setWordWrap, + expandUnchanged, + toggleExpandUnchanged, + wrapperRef, + }); const controlledSelection = state.showCommentForm ? state.selectedLines : null; diff --git a/apps/web/components/diff/file-diff-viewer.tsx b/apps/web/components/diff/file-diff-viewer.tsx index c53ce22dc6..754ea1b3e5 100644 --- a/apps/web/components/diff/file-diff-viewer.tsx +++ b/apps/web/components/diff/file-diff-viewer.tsx @@ -33,6 +33,8 @@ interface FileDiffViewerProps { expandUnchanged?: boolean; /** Callback when expand-unchanged is toggled (controlled mode) */ onToggleExpandUnchanged?: () => void; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; } /** @@ -67,6 +69,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({ baseRef, expandUnchanged, onToggleExpandUnchanged, + repo, }: FileDiffViewerProps) { const data = useMemo(() => transformGitDiff(filePath, diff, status), [filePath, diff, status]); @@ -92,6 +95,7 @@ export const FileDiffViewer = memo(function FileDiffViewer({ baseRef={baseRef} expandUnchanged={expandUnchanged} onToggleExpandUnchanged={onToggleExpandUnchanged} + repo={repo} /> ); }); diff --git a/apps/web/components/diff/use-diff-viewer-state.ts b/apps/web/components/diff/use-diff-viewer-state.ts index a66b7c5e89..69f1b7c1ae 100644 --- a/apps/web/components/diff/use-diff-viewer-state.ts +++ b/apps/web/components/diff/use-diff-viewer-state.ts @@ -136,6 +136,8 @@ type UseDiffViewerStateOpts = { enableExpansion?: boolean; /** Base git ref for fetching old content (e.g., "origin/main", "HEAD~1") */ baseRef?: string; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; }; function useDiffViewerAnnotations({ @@ -320,6 +322,7 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { onRevertBlock, enableExpansion = false, baseRef, + repo, } = opts; const [selectedLines, setSelectedLines] = useState(null); @@ -348,6 +351,7 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { baseRef, fileDiffMetadata: baseDiffMetadata, enableExpansion, + repo, }); const { revertInfoRef, handleRevertBlock } = useRevertBlock(data.filePath, onRevertBlock); diff --git a/apps/web/components/diff/use-expandable-diff.ts b/apps/web/components/diff/use-expandable-diff.ts index 84567952ea..d434fc85eb 100644 --- a/apps/web/components/diff/use-expandable-diff.ts +++ b/apps/web/components/diff/use-expandable-diff.ts @@ -12,6 +12,8 @@ type UseExpandableDiffOptions = { baseRef: string | undefined; fileDiffMetadata: FileDiffMetadata | null; enableExpansion?: boolean; + /** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */ + repo?: string; }; type UseExpandableDiffResult = { @@ -36,9 +38,10 @@ async function fetchOldContent( sessionId: string, filePath: string, baseRef: string, + repo?: string, ): Promise { try { - const res = await requestFileContentAtRef(client, sessionId, filePath, baseRef); + const res = await requestFileContentAtRef(client, sessionId, filePath, baseRef, repo); if (res.is_binary) throw new Error("Cannot expand binary files"); if (!res.error) return res.content; // File not found at ref is expected for new files - return empty string @@ -57,11 +60,12 @@ async function fetchNewContent( client: WsClient, sessionId: string, filePath: string, + repo?: string, ): Promise { try { // Fetch from working tree (current file on disk), not HEAD. // The diff shows working tree changes, so newLines must match. - const res = await requestFileContent(client, sessionId, filePath); + const res = await requestFileContent(client, sessionId, filePath, repo); if (res.is_binary) throw new Error("Cannot expand binary files"); if (!res.error) return res.content; // File not found is expected for deleted files - return empty string @@ -80,11 +84,14 @@ async function fetchExpansionLines( sessionId: string, filePath: string, baseRef: string | undefined, + repo: string | undefined, ) { const client = getWebSocketClient(); if (!client) throw new Error("WebSocket client not available"); - const newContent = await fetchNewContent(client, sessionId, filePath); - const oldContent = baseRef ? await fetchOldContent(client, sessionId, filePath, baseRef) : ""; + const newContent = await fetchNewContent(client, sessionId, filePath, repo); + const oldContent = baseRef + ? await fetchOldContent(client, sessionId, filePath, baseRef, repo) + : ""; return { oldLines: oldContent.split(SPLIT_WITH_NEWLINES), newLines: newContent.split(SPLIT_WITH_NEWLINES), @@ -104,6 +111,7 @@ export function useExpandableDiff({ baseRef, fileDiffMetadata, enableExpansion = false, + repo, }: UseExpandableDiffOptions): UseExpandableDiffResult { const requestVersionRef = useRef(0); const [loadedContent, setLoadedContent] = useState<{ @@ -121,7 +129,7 @@ export function useExpandableDiff({ requestVersionRef.current += 1; setLoadedContent(null); setError(null); - }, [sessionId, filePath, baseRef, fileDiffMetadata]); + }, [sessionId, filePath, baseRef, repo, fileDiffMetadata]); const loadContent = useCallback(async () => { if (!sessionId || !enableExpansion || loadedContent || isLoading) return; @@ -131,7 +139,7 @@ export function useExpandableDiff({ setError(null); try { - const lines = await fetchExpansionLines(sessionId, filePath, baseRef); + const lines = await fetchExpansionLines(sessionId, filePath, baseRef, repo); if (version === requestVersionRef.current) setLoadedContent(lines); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to load file content"; @@ -140,7 +148,7 @@ export function useExpandableDiff({ } finally { setIsLoading(false); } - }, [sessionId, filePath, baseRef, enableExpansion, loadedContent, isLoading]); + }, [sessionId, filePath, baseRef, repo, enableExpansion, loadedContent, isLoading]); const metadata = useMemo(() => { if (!fileDiffMetadata) return null; diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx index 3a35e31341..875a9e6e14 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -17,7 +17,7 @@ import { Separator } from "@kandev/ui/separator"; import { ScrollArea } from "@kandev/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; -import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { prPanelLabel } from "@/components/github/pr-utils"; import { usePRFeedback } from "@/hooks/domains/github/use-pr-feedback"; import { useCommentsStore } from "@/lib/state/slices/comments"; @@ -41,12 +41,21 @@ import { CommentsSection } from "./pr-comments-section"; type PRDetailPanelProps = { panelId: string; + /** Per-PR params; multi-repo panels carry prKey="//". */ + params?: { prKey?: string }; }; -export function PRDetailPanelComponent({ panelId }: PRDetailPanelProps) { - const pr = useActiveTaskPR(); +export function PRDetailPanelComponent({ panelId, params }: PRDetailPanelProps) { + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const { prs } = useTaskPR(activeTaskId); + const activePR = useActiveTaskPR(); const sessionId = useAppStore((s) => s.tasks.activeSessionId); + // Multi-repo: when the panel was opened with a prKey, render the matching + // TaskPR. Falls back to the active (primary) PR for legacy single-repo + // panels that pre-date the prKey param. + const pr = (params?.prKey ? prs.find((p) => prTaskKey(p) === params.prKey) : null) ?? activePR; + useEffect(() => { const title = pr ? prPanelLabel(pr.pr_number) : "Pull Request"; setPanelTitle(panelId, title); @@ -67,6 +76,11 @@ export function PRDetailPanelComponent({ panelId }: PRDetailPanelProps) { ); } +/** Stable per-PR key used by addPRPanel and the multi-PR topbar buttons. */ +export function prTaskKey(pr: TaskPR): string { + return `${pr.owner}/${pr.repo}/${pr.pr_number}`; +} + // --- Add PR feedback as chat context --- function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) { diff --git a/apps/web/components/github/pr-task-icon.test.ts b/apps/web/components/github/pr-task-icon.test.ts index b9e438efcb..613ff04f7b 100644 --- a/apps/web/components/github/pr-task-icon.test.ts +++ b/apps/web/components/github/pr-task-icon.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + aggregatePRStatusColor, getPRStatusColor, getPRTooltip, isPRAwaitingReview, @@ -269,6 +270,43 @@ describe("isPRAwaitingReview", () => { }); }); +describe("aggregatePRStatusColor", () => { + it("returns muted for empty list", () => { + expect(aggregatePRStatusColor([])).toBe("text-muted-foreground"); + }); + + it("surfaces the worst-of state — one red dominates a green sibling", () => { + const green = makePR({ + state: "open", + review_state: "approved", + checks_state: "success", + mergeable_state: "clean", + }); + const red = makePR({ + state: "open", + review_state: "changes_requested", + checks_state: "success", + }); + expect(aggregatePRStatusColor([green, red])).toBe("text-red-500"); + }); + + it("returns emerald only when all PRs are ready to merge", () => { + const ready = makePR({ + state: "open", + review_state: "approved", + checks_state: "success", + mergeable_state: "clean", + }); + expect(aggregatePRStatusColor([ready, ready])).toBe("text-emerald-400"); + }); + + it("yellow CI pending beats merged purple", () => { + const pending = makePR({ state: "open", checks_state: "pending" }); + const merged = makePR({ state: "merged" }); + expect(aggregatePRStatusColor([merged, pending])).toBe("text-yellow-500"); + }); +}); + describe("getPRTooltip", () => { it("includes 'Ready to merge' when ready", () => { const pr = makePR({ diff --git a/apps/web/components/github/pr-task-icon.tsx b/apps/web/components/github/pr-task-icon.tsx index 3561e1c64d..f15cdf0159 100644 --- a/apps/web/components/github/pr-task-icon.tsx +++ b/apps/web/components/github/pr-task-icon.tsx @@ -6,6 +6,18 @@ import { cn } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; +const STATUS_RANK: Record = { + // Higher = more attention-worthy. Drives the aggregated icon color when a + // task has multiple PRs (we surface the worst state). + "text-red-500": 5, + "text-yellow-500": 4, + "text-sky-400": 3, + "text-emerald-400": 2, + "text-green-500": 1, + "text-purple-500": 0, + "text-muted-foreground": 0, +}; + // Requires checks_state === "success" (not just "") so repos with no CI configured // won't trigger ready-to-merge on mergeable_state=clean alone. export function isPRReadyToMerge(pr: TaskPR): boolean { @@ -62,17 +74,41 @@ export function getPRTooltip(pr: TaskPR): string { return parts.join(" | "); } +/** + * Picks the most attention-worthy color across N PRs. For multi-repo tasks one + * red PR should dominate the visual even if the others are green. + */ +export function aggregatePRStatusColor(prs: TaskPR[]): string { + if (prs.length === 0) return "text-muted-foreground"; + let bestColor = "text-muted-foreground"; + let bestRank = -1; + for (const pr of prs) { + const color = getPRStatusColor(pr); + const rank = STATUS_RANK[color] ?? 0; + if (rank > bestRank) { + bestRank = rank; + bestColor = color; + } + } + return bestColor; +} + export function PRTaskIcon({ taskId }: { taskId: string }) { - const pr = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null); + const prs = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null); - if (!pr) return null; + if (!prs || prs.length === 0) return null; + if (prs.length === 1) return ; + return ; +} +function SinglePRIcon({ taskId, pr }: { taskId: string; pr: TaskPR }) { return ( @@ -83,3 +119,35 @@ export function PRTaskIcon({ taskId }: { taskId: string }) { ); } + +function MultiPRIcon({ taskId, prs }: { taskId: string; prs: TaskPR[] }) { + const aggregateColor = aggregatePRStatusColor(prs); + const allReady = prs.every(isPRReadyToMerge); + return ( + + + + + {prs.length} + + + +
    + {prs.map((pr) => ( +
    + + + + {getPRTooltip(pr)} +
    + ))} +
    +
    +
    + ); +} diff --git a/apps/web/components/github/pr-topbar-button.tsx b/apps/web/components/github/pr-topbar-button.tsx index 74ce4ef58d..f3e37ca7b1 100644 --- a/apps/web/components/github/pr-topbar-button.tsx +++ b/apps/web/components/github/pr-topbar-button.tsx @@ -1,12 +1,31 @@ "use client"; import { memo } from "react"; -import { IconGitPullRequest, IconCheck, IconX, IconClock } from "@tabler/icons-react"; +import { + IconGitPullRequest, + IconCheck, + IconX, + IconClock, + IconChevronDown, +} from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; import { useDockviewStore } from "@/lib/state/dockview-store"; -import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr"; -import { getPRStatusColor, isPRReadyToMerge } from "@/components/github/pr-task-icon"; +import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { + aggregatePRStatusColor, + getPRStatusColor, + isPRReadyToMerge, +} from "@/components/github/pr-task-icon"; +import { prTaskKey } from "@/components/github/pr-detail-panel"; import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; @@ -36,31 +55,86 @@ function PRStatusIcon({ pr }: { pr: TaskPR }) { export const PRTopbarButton = memo(function PRTopbarButton() { const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); - // useTaskPR fetches if not in store; useActiveTaskPR just reads - useTaskPR(activeTaskId); - const pr = useActiveTaskPR(); + // useTaskPR fetches if not in store and returns the full per-task list so + // multi-repo tasks can surface every PR (one button for single-repo, a + // dropdown summary for 2+ so the topbar doesn't blow up horizontally). + const { prs } = useTaskPR(activeTaskId); + + if (prs.length === 0) return null; + if (prs.length === 1) return ; + return ; +}); + +function PRSingleButton({ pr }: { pr: TaskPR }) { const addPRPanel = useDockviewStore((s) => s.addPRPanel); - - if (!pr) return null; - + const tooltip = `${pr.owner}/${pr.repo} #${pr.pr_number} — ${pr.pr_title}`; return ( - {pr.pr_title} + {tooltip} ); -}); +} + +function PRMultiButton({ prs }: { prs: TaskPR[] }) { + const addPRPanel = useDockviewStore((s) => s.addPRPanel); + const aggColor = aggregatePRStatusColor(prs); + return ( + + + + + + + + {prs.length} pull requests linked to this task — open one + + + Pull requests + + {prs.map((pr) => ( + addPRPanel(prTaskKey(pr))} + className="cursor-pointer gap-2" + data-testid={`pr-topbar-menu-item-${pr.pr_number}`} + > + +
    + + {pr.repo} #{pr.pr_number} + + {pr.pr_title} +
    + +
    + ))} +
    +
    + ); +} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 8beafcbe33..804d893c67 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -13,6 +13,7 @@ import { import { Checkbox } from "@kandev/ui/checkbox"; import { Card, CardContent } from "@kandev/ui/card"; import { Badge } from "@kandev/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; import { DropdownMenu, @@ -42,6 +43,8 @@ export interface Task { description?: string; position?: number; repositoryId?: string; + /** All repositories linked to the task; used to render a "+N" chip for multi-repo. */ + repositories?: Array<{ id: string; repository_id: string; position: number }>; // Workflow fields sessionCount?: number | null; primarySessionId?: string | null; @@ -63,7 +66,8 @@ export interface WorkflowStep { interface KanbanCardProps { task: Task; - repositoryName?: string | null; + /** Display names of every repository linked to the task, primary first. */ + repositoryNames?: string[]; onClick?: (task: Task) => void; onEdit?: (task: Task) => void; onDelete?: (task: Task) => void; @@ -79,24 +83,58 @@ interface KanbanCardProps { isMultiSelectMode?: boolean; } +const REPO_CHIPS_VISIBLE = 2; + +function RepoChipRow({ repoNames }: { repoNames: string[] }) { + if (repoNames.length === 0) return null; + const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE); + const overflow = repoNames.slice(REPO_CHIPS_VISIBLE); + const row = ( +
    + {visible.map((name) => ( + + {name} + + ))} + {overflow.length > 0 && ( + + +{overflow.length} + + )} +
    + ); + if (repoNames.length <= 1) return row; + return ( + + {row} + +
    + {repoNames.map((name) => ( + {name} + ))} +
    +
    +
    + ); +} + function KanbanCardBody({ task, - repoName, + repoNames, actions, }: { task: Task; - repoName: string | null; + repoNames: string[]; actions?: React.ReactNode; }) { return ( <>
    - {repoName && ( -

    - {repoName} -

    - )} +

    - + ); @@ -419,7 +457,7 @@ function KanbanCardCheckbox({ export function KanbanCard({ task, - repositoryName, + repositoryNames, onClick, onEdit, onDelete, @@ -492,7 +530,7 @@ export function KanbanCard({

    state.repositories.itemsByWorkspaceId); - const repository = useMemo( - () => - (Object.values(repositoriesByWorkspace).flat() as Repository[]).find( - (repo) => repo.id === task.repositoryId, - ) ?? null, - [repositoriesByWorkspace, task.repositoryId], + const repositoryNames = useMemo( + () => resolveTaskRepositoryNames(task, Object.values(repositoriesByWorkspace).flat()), + [repositoriesByWorkspace, task], ); - const repositoryName = repository ? getRepositoryDisplayName(repository.local_path) : null; return ( ); } + +/** + * Resolves a task's linked repositories to display names. Primary first + * (`task.repositoryId`), then any others ordered by `task.repositories[].position`. + * Skips unresolved IDs (repo deleted / not yet hydrated). + */ +export function resolveTaskRepositoryNames(task: Task, repositories: Repository[]): string[] { + const byId = new Map(repositories.map((repo) => [repo.id, repo])); + const seen = new Set(); + const names: string[] = []; + + const push = (id: string | undefined) => { + if (!id || seen.has(id)) return; + const repo = byId.get(id); + if (!repo) return; + seen.add(id); + const display = getRepositoryDisplayName(repo.local_path); + if (display) names.push(display); + }; + + push(task.repositoryId); + const ordered = [...(task.repositories ?? [])].sort((a, b) => a.position - b.position); + for (const link of ordered) push(link.repository_id); + return names; +} diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index f0e7887475..ed2c794656 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -2,9 +2,9 @@ import { useMemo } from "react"; import { useDroppable } from "@dnd-kit/core"; -import { KanbanCard, Task } from "./kanban-card"; +import { KanbanCard, resolveTaskRepositoryNames, Task } from "./kanban-card"; import { Badge } from "@kandev/ui/badge"; -import { cn, getRepositoryDisplayName } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; import type { Repository } from "@/lib/types/http"; @@ -66,13 +66,6 @@ export function KanbanColumn({ [repositoriesByWorkspace], ); - // Helper function to get repository name for a task - const getRepositoryName = (repositoryId?: string): string | null => { - if (!repositoryId) return null; - const repository = repositories.find((repo) => repo.id === repositoryId); - return repository ? getRepositoryDisplayName(repository.local_path) : null; - }; - return (
    , files: CumulativeDiff["files"], gitStatusFiles: Record | null, ) { - for (const [path, file] of Object.entries(files)) { - if (fileMap.has(path)) continue; + // Multi-repo: backend always stamps each per-file payload with + // `repository_name` + the repo-relative `path`. Single-repo (or legacy) + // payloads keep the bare path on the map key. We trust `file.path` to be + // set in the multi-repo shape — a missing `path` would be a backend + // contract violation and indicates the caller is on an outdated agentctl; + // surface it loudly via a console warning rather than silently injecting + // the composite map key into the displayed path (which used to corrupt + // the file tree node names). + for (const [mapKey, file] of Object.entries(files)) { + const repoName = file.repository_name; + const path = file.path; + if (!path) { + console.warn("[review] cumulative diff entry missing `path` field; skipping", { + mapKey, + repoName, + }); + continue; + } + const key = fileMapKey(path, repoName); + if (fileMap.has(key)) continue; const diff = file.diff ? normalizeDiffContent(file.diff) : ""; if (diff) { - const hasUncommitted = gitStatusFiles != null && path in gitStatusFiles; - fileMap.set(path, { + const matchingUncommitted = findUncommittedByPathAndRepo(gitStatusFiles, path, repoName); + fileMap.set(key, { path, diff, status: file.status || "modified", additions: file.additions ?? 0, deletions: file.deletions ?? 0, - staged: gitStatusFiles?.[path]?.staged ?? false, - source: hasUncommitted ? "uncommitted" : "committed", + staged: matchingUncommitted?.staged ?? false, + source: matchingUncommitted ? "uncommitted" : "committed", + repository_name: repoName, }); } } } +/** Looks up a FileInfo in the (possibly composite-keyed) gitStatus map by + * (path, repository_name). When repo is undefined or empty (single-repo), + * returns the first match by path. */ +function findUncommittedByPathAndRepo( + gitStatusFiles: Record | null, + path: string, + repositoryName: string | undefined, +): FileInfo | undefined { + if (!gitStatusFiles) return undefined; + for (const file of Object.values(gitStatusFiles)) { + if (file.path !== path) continue; + if (!repositoryName) return file; + if (file.repository_name === repositoryName) return file; + } + return undefined; +} + function addUncommittedFiles( fileMap: Map, gitStatusFiles: Record, ) { - for (const [path, file] of Object.entries(gitStatusFiles)) { - if (fileMap.has(path)) continue; + for (const file of Object.values(gitStatusFiles)) { + const key = fileMapKey(file.path, file.repository_name); + if (fileMap.has(key)) continue; const diff = file.diff ? normalizeDiffContent(file.diff) : ""; if (diff) - fileMap.set(path, { - path, + fileMap.set(key, { + path: file.path, diff, status: file.status, additions: file.additions ?? 0, deletions: file.deletions ?? 0, staged: file.staged, source: "uncommitted", + repository_name: file.repository_name, }); } } @@ -113,26 +169,54 @@ function computeReviewSets( const reviewed = new Set(); const stale = new Set(); for (const file of allFiles) { - const reviewState = reviews.get(file.path); + const key = reviewFileKey(file); + const reviewState = reviews.get(key); if (!reviewState?.reviewed) continue; const currentHash = hashDiff(file.diff); - if (reviewState.diffHash && reviewState.diffHash !== currentHash) stale.add(file.path); - else reviewed.add(file.path); + if (reviewState.diffHash && reviewState.diffHash !== currentHash) stale.add(key); + else reviewed.add(key); } return { reviewedFiles: reviewed, staleFiles: stale }; } +/** + * Counts diff comments per file, scoped by repo when known. Multi-repo: + * comments carrying `repositoryId` are matched only against files in that + * repo (translated from `repository_name` via `repositoryNameToId`); legacy + * comments without `repositoryId` and same-repo unattributed comments + * match by path. Returned record is keyed by `reviewFileKey(file)` so the + * file tree's per-row badge correctly disambiguates same-named files. + */ function computeCommentCounts( byId: Record, sessionCommentIds: string[] | undefined, + allFiles: ReviewFile[], + repositoryNameToId: Map, ): Record { const counts: Record = {}; if (!sessionCommentIds) return counts; + + type BucketKey = string; + const bucketKey = (repoId: string, path: string) => `${repoId}::${path}`; + const bucket = new Map(); for (const id of sessionCommentIds) { const comment = byId[id]; - if (comment && isDiffComment(comment)) { - counts[comment.filePath] = (counts[comment.filePath] ?? 0) + 1; + if (!comment || !isDiffComment(comment)) continue; + const k = bucketKey(comment.repositoryId ?? "", comment.filePath); + bucket.set(k, (bucket.get(k) ?? 0) + 1); + } + + for (const file of allFiles) { + let total = 0; + if (file.repository_name) { + const repoId = repositoryNameToId.get(file.repository_name) ?? ""; + if (repoId) total += bucket.get(bucketKey(repoId, file.path)) ?? 0; } + // Path-only comments (legacy / pre-multi-repo data) match every file + // sharing the path; this preserves the prior single-repo behavior and + // keeps existing comments visible after the multi-repo rollout. + total += bucket.get(bucketKey("", file.path)) ?? 0; + if (total > 0) counts[reviewFileKey(file)] = total; } return counts; } @@ -157,18 +241,20 @@ function useReviewDialogHandlers(opts: ReviewDialogHandlerOptions) { window.dispatchEvent(new CustomEvent("diff-view-mode-change", { detail: mode })); }, []); - const handleSelectFile = useCallback((path: string, setSelectedFile: (p: string) => void) => { - setSelectedFile(path); + const handleSelectFile = useCallback((key: string, setSelectedFile: (k: string) => void) => { + setSelectedFile(key); // Note: scrolling is now handled by FileDiffSection when isSelected changes // This ensures proper timing after the section expands }, []); const handleToggleReviewed = useCallback( - (path: string, reviewed: boolean) => { + (key: string, reviewed: boolean) => { if (reviewed) { - const file = allFiles.find((f) => f.path === path); - markReviewed(path, file ? hashDiff(file.diff) : ""); - } else markUnreviewed(path); + // Look up by composite key so two same-name files in different repos + // don't share their reviewed/diff-hash. + const file = allFiles.find((f) => reviewFileKey(f) === key); + markReviewed(key, file ? hashDiff(file.diff) : ""); + } else markUnreviewed(key); }, [allFiles, markReviewed, markUnreviewed], ); @@ -182,9 +268,12 @@ function useReviewDialogHandlers(opts: ReviewDialogHandlerOptions) { ); const handleDiscard = useCallback( - async (path: string) => { + async (key: string) => { + // Caller passes the composite key; split out the repo + path so + // discard runs in the correct repo's worktree. + const { repositoryName, path } = splitFileKey(key); try { - const result = await discard([path]); + const result = await discard([path], repositoryName || undefined); if (result.success) toast({ title: "Changes discarded", description: path, variant: "success" }); else @@ -252,17 +341,33 @@ function useReviewDialogState(props: ReviewDialogProps) { () => computeReviewSets(allFiles, reviews), [allFiles, reviews], ); + // Build a `repository_name` → `repositoryId` map from the workspace repos + // slice. Comments store `repositoryId` (UUID) but ReviewFiles carry + // `repository_name` (e.g. "kandev"); the dialog needs both to scope the + // per-repo comment counts. Falls back to an empty map (legacy single-repo + // counts by path only). + const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const repositoryNameToId = useMemo(() => { + const m = new Map(); + for (const list of Object.values(reposByWorkspace)) { + for (const r of list) m.set(r.name, r.id); + } + return m; + }, [reposByWorkspace]); const commentCountByFile = useMemo( - () => computeCommentCounts(byId, sessionCommentIds), - [byId, sessionCommentIds], + () => computeCommentCounts(byId, sessionCommentIds, allFiles, repositoryNameToId), + [byId, sessionCommentIds, allFiles, repositoryNameToId], ); const totalCommentCount = useMemo( () => Object.values(commentCountByFile).reduce((sum, c) => sum + c, 0), [commentCountByFile], ); + // Composite-key the file refs so two files with the same path in different + // repos get distinct refs (otherwise auto-scroll-on-select / "selected" + // styling lands on the wrong row). const fileRefs = useMemo(() => { const refs = new Map>(); - for (const file of allFiles) refs.set(file.path, createRef()); + for (const file of allFiles) refs.set(reviewFileKey(file), createRef()); return refs; }, [allFiles]); const prevCountRef = useRef(null); diff --git a/apps/web/components/review/review-diff-list-grouping.test.tsx b/apps/web/components/review/review-diff-list-grouping.test.tsx new file mode 100644 index 0000000000..54afa2cd70 --- /dev/null +++ b/apps/web/components/review/review-diff-list-grouping.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { createRef, type ReactNode } from "react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import type { ReviewFile } from "./types"; + +// FileDiffViewer is heavy and irrelevant to the grouping test — stub it. +vi.mock("@/components/diff", () => ({ + FileDiffViewer: ({ filePath }: { filePath: string }) => ( +
    {filePath}
    + ), +})); + +vi.mock("@/components/editors/file-actions-dropdown", () => ({ + FileActionsDropdown: () => null, +})); + +vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => null })); +vi.mock("@/lib/ws/workspace-files", () => ({ + requestFileContent: vi.fn(), + updateFileContent: vi.fn(), +})); +vi.mock("@/hooks/use-global-view-mode", () => ({ + useGlobalViewMode: () => ["unified", vi.fn()], +})); +vi.mock("@/hooks/domains/comments/use-run-comment", () => ({ + useRunComment: () => ({ runComment: vi.fn() }), +})); +vi.mock("@/components/state-provider", () => ({ + useAppStore: () => null, +})); +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +import { ReviewDiffList } from "./review-diff-list"; + +afterEach(cleanup); + +function file(path: string, repo?: string): ReviewFile { + return { + path, + diff: "@@ -1 +1 @@\n-a\n+b\n", + status: "modified", + additions: 1, + deletions: 1, + staged: false, + source: "uncommitted", + repository_name: repo, + }; +} + +const baseProps = { + reviewedFiles: new Set(), + staleFiles: new Set(), + sessionId: "sess", + autoMarkOnScroll: false, + wordWrap: false, + selectedFile: null, + onToggleReviewed: () => undefined, + onDiscard: () => undefined, + fileRefs: new Map>(), +}; + +// FileDiffSection's toolbar uses Radix Tooltip which needs a provider. +function withTooltips(node: ReactNode) { + return {node}; +} + +describe("ReviewDiffList — multi-repo grouping", () => { + it("renders no repo header when all files lack a repository_name", () => { + const refs = new Map([["a.ts", createRef()]]); + render(withTooltips()); + expect(screen.queryAllByTestId("changes-repo-header")).toHaveLength(0); + }); + + it("renders one header per repo when 2+ repos are present", () => { + const files: ReviewFile[] = [ + file("src/app.tsx", "frontend"), + file("src/api.ts", "frontend"), + file("handlers/task.go", "backend"), + ]; + const refs = new Map(files.map((f) => [f.path, createRef()])); + render(withTooltips()); + + const headers = screen.getAllByTestId("changes-repo-header"); + expect(headers).toHaveLength(2); + expect(headers[0].textContent).toContain("frontend"); + expect(headers[0].textContent).toContain("2 files"); + expect(headers[1].textContent).toContain("backend"); + expect(headers[1].textContent).toContain("1 file"); + }); + + it("shows a header for a single named repo too (so user sees the label)", () => { + const refs = new Map([["a.ts", createRef()]]); + render( + withTooltips( + , + ), + ); + expect(screen.getByTestId("changes-repo-header").textContent).toContain("only-repo"); + }); + + it("groups files into per-repo sections with the right counts", () => { + const files: ReviewFile[] = [file("a.ts", "x"), file("b.ts", "y"), file("c.ts", "x")]; + const refs = new Map(files.map((f) => [f.path, createRef()])); + render(withTooltips()); + const groups = screen.getAllByTestId("changes-repo-group"); + expect(groups).toHaveLength(2); + // The diff body itself is lazy-loaded via IntersectionObserver (which + // doesn't fire in happy-dom), so verify grouping by counting file path + // labels in each group's header rather than the inner diff body. + const xGroup = groups.find((g) => g.getAttribute("data-repository-name") === "x"); + const yGroup = groups.find((g) => g.getAttribute("data-repository-name") === "y"); + // FileDiffHeader always renders the file path in a span; count those. + expect(xGroup?.textContent).toContain("a.ts"); + expect(xGroup?.textContent).toContain("c.ts"); + expect(xGroup?.textContent).not.toContain("b.ts"); + expect(yGroup?.textContent).toContain("b.ts"); + expect(yGroup?.textContent).not.toContain("a.ts"); + }); +}); diff --git a/apps/web/components/review/review-diff-list-groups.tsx b/apps/web/components/review/review-diff-list-groups.tsx new file mode 100644 index 0000000000..2f3d054ed0 --- /dev/null +++ b/apps/web/components/review/review-diff-list-groups.tsx @@ -0,0 +1,24 @@ +"use client"; + +/** + * Sticky per-repository section header rendered above each group of files in + * the changes panel. The grouping logic lives in @/lib/group-by-repo so the + * Review dialog and the dockview Changes tab share the same Map-based + * bucketer. Only this presentational header is review-specific. + */ +export function RepoGroupHeader({ name, fileCount }: { name: string; fileCount: number }) { + // The empty-name group ("uncategorised" — no repository_name on its files) + // gets a generic label so the user understands what they're looking at. + const label = name || "Other changes"; + return ( +
    + {label} + + {fileCount} {fileCount === 1 ? "file" : "files"} + +
    + ); +} diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx index f1a3311189..60f0fef5f9 100644 --- a/apps/web/components/review/review-diff-list.tsx +++ b/apps/web/components/review/review-diff-list.tsx @@ -1,41 +1,22 @@ "use client"; -import { memo, useEffect, useRef, useState, useCallback } from "react"; -import { - IconAlertTriangle, - IconArrowBackUp, - IconChevronDown, - IconChevronRight, - IconCopy, - IconFold, - IconFoldDown, - IconLayoutColumns, - IconLayoutRows, - IconPencil, - IconTextWrap, - IconEye, -} from "@tabler/icons-react"; +import { memo, useEffect, useMemo, useRef, useState, useCallback } from "react"; +import { IconAlertTriangle, IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { Checkbox } from "@kandev/ui/checkbox"; -import { Button } from "@kandev/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { FileDiffViewer } from "@/components/diff"; import type { RevertBlockInfo } from "@/components/diff"; -import { FileActionsDropdown } from "@/components/editors/file-actions-dropdown"; import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileContent, updateFileContent } from "@/lib/ws/workspace-files"; import { generateUnifiedDiff, calculateHash } from "@/lib/utils/file-diff"; -import { useGlobalViewMode } from "@/hooks/use-global-view-mode"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { useRunComment } from "@/hooks/domains/comments/use-run-comment"; import type { DiffComment } from "@/lib/diff/types"; -import { diffSkipReasonLabel } from "./types"; +import { diffSkipReasonLabel, reviewFileKey } from "./types"; import type { ReviewFile } from "./types"; - -function isMarkdownPath(filePath: string): boolean { - const ext = filePath.split(".").pop()?.toLowerCase(); - return ext === "md" || ext === "mdx"; -} +import { RepoGroupHeader } from "./review-diff-list-groups"; +import { FileDiffToolbar } from "./review-diff-toolbar"; +import { groupByRepositoryName } from "@/lib/group-by-repo"; type ReviewDiffListProps = { files: ReviewFile[]; @@ -67,35 +48,68 @@ export const ReviewDiffList = memo(function ReviewDiffList({ fileRefs, }: ReviewDiffListProps) { const scrollContainerRef = useRef(null); - // Find index of selected file - we need to force-load all files up to it - const selectedIndex = selectedFile ? files.findIndex((f) => f.path === selectedFile) : -1; + // All in-memory state (selectedFile, reviewedFiles, staleFiles, fileRefs) + // is keyed by `reviewFileKey(file)` so two files at the same path in + // different repos (e.g. `kandev/README.md` + `lvc/README.md`) get + // distinct rows. Without this every multi-repo review with same-named + // files would mark them all reviewed when one is checked. + const selectedIndex = selectedFile + ? files.findIndex((f) => reviewFileKey(f) === selectedFile) + : -1; + const groups = useMemo(() => groupByRepositoryName(files, (f) => f.repository_name), [files]); + const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== ""; return (
    - {files.map((file, index) => ( - = 0 && index <= selectedIndex} - onToggleReviewed={onToggleReviewed} - onDiscard={onDiscard} - onOpenFile={onOpenFile} - onPreviewMarkdown={onPreviewMarkdown} - sectionRef={fileRefs.get(file.path)} - scrollContainer={scrollContainerRef} - /> + {groups.map((group) => ( +
    + {showRepoHeaders && ( + + )} + {group.items.map((file) => { + const key = reviewFileKey(file); + return ( + = 0 && + files.findIndex((f) => reviewFileKey(f) === key) <= selectedIndex + } + onToggleReviewed={onToggleReviewed} + onDiscard={onDiscard} + onOpenFile={onOpenFile} + onPreviewMarkdown={onPreviewMarkdown} + sectionRef={fileRefs.get(key)} + scrollContainer={scrollContainerRef} + /> + ); + })} +
    ))}
    ); }); +// Per-repo grouping helpers live in ./review-diff-list-groups so this file +// stays under the 600-line lint cap. + type FileDiffSectionProps = { file: ReviewFile; + /** Composite per-file key from `reviewFileKey(file)` — used as the arg + * to `onToggleReviewed` / `onDiscard` so callers can disambiguate + * same-named files in different repos. */ + fileKey: string; isReviewed: boolean; isStale: boolean; sessionId: string; @@ -103,8 +117,8 @@ type FileDiffSectionProps = { wordWrap: boolean; isSelected?: boolean; forceLoad?: boolean; - onToggleReviewed: (path: string, reviewed: boolean) => void; - onDiscard: (path: string) => void; + onToggleReviewed: (key: string, reviewed: boolean) => void; + onDiscard: (key: string) => void; onOpenFile?: (filePath: string) => void; onPreviewMarkdown?: (filePath: string) => void; sectionRef?: React.RefObject; @@ -136,8 +150,9 @@ type AutoMarkArgs = { autoMarkOnScroll: boolean; isReviewed: boolean; isStale: boolean; - filePath: string; - onToggleReviewed: (path: string, reviewed: boolean) => void; + /** Composite per-file key (matches what onToggleReviewed expects). */ + fileKey: string; + onToggleReviewed: (key: string, reviewed: boolean) => void; scrollContainer: React.RefObject; }; @@ -145,7 +160,7 @@ function useAutoMarkOnScroll({ autoMarkOnScroll, isReviewed, isStale, - filePath, + fileKey, onToggleReviewed, scrollContainer, }: AutoMarkArgs) { @@ -167,143 +182,18 @@ function useAutoMarkOnScroll({ !autoMarkedRef.current ) { autoMarkedRef.current = true; - console.debug("[review] auto-mark reviewed:", filePath); - onToggleReviewed(filePath, true); + console.debug("[review] auto-mark reviewed:", fileKey); + onToggleReviewed(fileKey, true); } }, { threshold: 0, root }, ); observer.observe(sentinel); return () => observer.disconnect(); - }, [autoMarkOnScroll, filePath, isReviewed, isStale, onToggleReviewed, scrollContainer]); + }, [autoMarkOnScroll, fileKey, isReviewed, isStale, onToggleReviewed, scrollContainer]); return scrollSentinelRef; } -const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"; -const iconBtnActive = "h-6 w-6 p-0 cursor-pointer opacity-100 bg-muted"; - -type FileDiffToolbarProps = { - diff: string; - filePath: string; - sessionId: string; - source: string; - wordWrap: boolean; - expandUnchanged: boolean; - onDiscard: () => void; - onOpenFile?: (filePath: string) => void; - onPreviewMarkdown?: (filePath: string) => void; - onToggleExpandUnchanged: () => void; - onToggleWordWrap: () => void; -}; - -function ToolbarIconBtn({ - onClick, - tooltip, - active, - children, - className, -}: { - onClick: () => void; - tooltip: string; - active?: boolean; - children: React.ReactNode; - className?: string; -}) { - return ( - - - - - {tooltip} - - ); -} - -function FileDiffToolbar(props: FileDiffToolbarProps) { - const { - diff, - filePath, - sessionId, - source, - wordWrap, - expandUnchanged, - onDiscard, - onOpenFile, - onPreviewMarkdown, - onToggleExpandUnchanged, - onToggleWordWrap, - } = props; - const [globalViewMode, setGlobalViewMode] = useGlobalViewMode(); - const handleCopyDiff = useCallback(() => { - navigator.clipboard.writeText(diff || ""); - }, [diff]); - const handleToggleViewMode = useCallback( - () => setGlobalViewMode(globalViewMode === "split" ? "unified" : "split"), - [globalViewMode, setGlobalViewMode], - ); - return ( -
    - - - - - {expandUnchanged ? ( - - ) : ( - - )} - - - - - - {globalViewMode === "split" ? ( - - ) : ( - - )} - - {onPreviewMarkdown && isMarkdownPath(filePath) && ( - onPreviewMarkdown(filePath)} tooltip="Preview markdown"> - - - )} - {onOpenFile && ( - onOpenFile(filePath)} tooltip="Edit"> - - - )} - - {source === "uncommitted" && ( - - - - )} -
    - ); -} - type FileDiffHeaderProps = { file: ReviewFile; isReviewed: boolean; @@ -411,11 +301,16 @@ function useCommentRunHandler(sessionId: string) { ); } -async function revertBlock(sessionId: string, filePath: string, info: RevertBlockInfo) { +async function revertBlock( + sessionId: string, + filePath: string, + info: RevertBlockInfo, + repo?: string, +) { const client = getWebSocketClient(); if (!client) return; try { - const response = await requestFileContent(client, sessionId, filePath); + const response = await requestFileContent(client, sessionId, filePath, repo); if (response.error) return; const currentContent = response.content; const hash = await calculateHash(currentContent); @@ -429,6 +324,7 @@ async function revertBlock(sessionId: string, filePath: string, info: RevertBloc path: filePath, diff: patch, originalHash: hash, + repo, }); } catch (err) { console.error("Failed to revert change block:", err); @@ -490,6 +386,7 @@ function renderDiffContent(opts: { hideHeader expandUnchanged={expandUnchanged} onToggleExpandUnchanged={onToggleExpandUnchanged} + repo={file.repository_name} /> {file.diff_skip_reason === "truncated" && (
    @@ -508,6 +405,7 @@ function renderDiffContent(opts: { function FileDiffSection({ file, + fileKey, isReviewed, isStale, sessionId, @@ -536,27 +434,31 @@ function FileDiffSection({ // Force load when visible via intersection observer, or forceLoad is true const shouldRenderContent = isVisible || !!forceLoad; useScrollIntoViewOnSelect(isSelected, sectionRef, setCollapsed); + // Auto-mark sends the composite key (matches the dialog's reviewed-set + // shape) so cross-repo same-named files don't all get marked when one + // scrolls past. const scrollSentinelRef = useAutoMarkOnScroll({ autoMarkOnScroll, isReviewed, isStale, - filePath: file.path, + fileKey, onToggleReviewed, scrollContainer, }); const handleCheckboxChange = useCallback( (checked: boolean | "indeterminate") => { - onToggleReviewed(file.path, checked === true); + onToggleReviewed(fileKey, checked === true); }, - [file.path, onToggleReviewed], + [fileKey, onToggleReviewed], ); const handleDiscard = useCallback(() => { - onDiscard(file.path); - }, [file.path, onDiscard]); + onDiscard(fileKey); + }, [fileKey, onDiscard]); const handleRevertBlock = useCallback( - (filePath: string, info: RevertBlockInfo) => revertBlock(sessionId, filePath, info), - [sessionId], + (filePath: string, info: RevertBlockInfo) => + revertBlock(sessionId, filePath, info, file.repository_name), + [sessionId, file.repository_name], ); const handleCommentRun = useCommentRunHandler(sessionId); diff --git a/apps/web/components/review/review-diff-toolbar.tsx b/apps/web/components/review/review-diff-toolbar.tsx new file mode 100644 index 0000000000..54fc50fde8 --- /dev/null +++ b/apps/web/components/review/review-diff-toolbar.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useCallback } from "react"; +import { + IconArrowBackUp, + IconCopy, + IconEye, + IconFold, + IconFoldDown, + IconLayoutColumns, + IconLayoutRows, + IconPencil, + IconTextWrap, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { FileActionsDropdown } from "@/components/editors/file-actions-dropdown"; +import { useGlobalViewMode } from "@/hooks/use-global-view-mode"; + +const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"; +const iconBtnActive = "h-6 w-6 p-0 cursor-pointer opacity-100 bg-muted"; + +function isMarkdownPath(filePath: string): boolean { + const ext = filePath.split(".").pop()?.toLowerCase(); + return ext === "md" || ext === "mdx"; +} + +export type FileDiffToolbarProps = { + diff: string; + filePath: string; + sessionId: string; + source: string; + wordWrap: boolean; + expandUnchanged: boolean; + onDiscard: () => void; + onOpenFile?: (filePath: string) => void; + onPreviewMarkdown?: (filePath: string) => void; + onToggleExpandUnchanged: () => void; + onToggleWordWrap: () => void; +}; + +function ToolbarIconBtn({ + onClick, + tooltip, + active, + children, + className, +}: { + onClick: () => void; + tooltip: string; + active?: boolean; + children: React.ReactNode; + className?: string; +}) { + return ( + + + + + {tooltip} + + ); +} + +export function FileDiffToolbar(props: FileDiffToolbarProps) { + const { + diff, + filePath, + sessionId, + source, + wordWrap, + expandUnchanged, + onDiscard, + onOpenFile, + onPreviewMarkdown, + onToggleExpandUnchanged, + onToggleWordWrap, + } = props; + const [globalViewMode, setGlobalViewMode] = useGlobalViewMode(); + const handleCopyDiff = useCallback(() => { + navigator.clipboard.writeText(diff || ""); + }, [diff]); + const handleToggleViewMode = useCallback( + () => setGlobalViewMode(globalViewMode === "split" ? "unified" : "split"), + [globalViewMode, setGlobalViewMode], + ); + return ( +
    + + + + + {expandUnchanged ? ( + + ) : ( + + )} + + + + + + {globalViewMode === "split" ? ( + + ) : ( + + )} + + {onPreviewMarkdown && isMarkdownPath(filePath) && ( + onPreviewMarkdown(filePath)} tooltip="Preview markdown"> + + + )} + {onOpenFile && ( + onOpenFile(filePath)} tooltip="Edit"> + + + )} + + {source === "uncommitted" && ( + + + + )} +
    + ); +} diff --git a/apps/web/components/review/review-file-tree.tsx b/apps/web/components/review/review-file-tree.tsx index 196b246156..914d49fc0d 100644 --- a/apps/web/components/review/review-file-tree.tsx +++ b/apps/web/components/review/review-file-tree.tsx @@ -13,7 +13,7 @@ import { Checkbox } from "@kandev/ui/checkbox"; import { cn } from "@kandev/ui/lib/utils"; import { FileIcon } from "@/components/ui/file-icon"; import type { ReviewFile, FileTreeNode } from "./types"; -import { buildFileTree } from "./types"; +import { buildFileTree, reviewFileKey } from "./types"; type ReviewFileTreeProps = { files: ReviewFile[]; @@ -107,10 +107,13 @@ function FileNode({ depth, }: Omit & { node: FileTreeNode }) { const file = node.file!; - const isReviewed = reviewedFiles.has(file.path); - const isStale = staleFiles.has(file.path); - const commentCount = commentCountByFile[file.path] ?? 0; - const isSelected = selectedFile === file.path; + // Composite key from reviewFileKey() so two same-name files in + // different repos don't share reviewed/stale/comment-count slots. + const key = reviewFileKey(file); + const isReviewed = reviewedFiles.has(key); + const isStale = staleFiles.has(key); + const commentCount = commentCountByFile[key] ?? 0; + const isSelected = selectedFile === key; return (
    onSelectFile(file.path)} + onClick={() => onSelectFile(key)} > { - onToggleReviewed(file.path, checked === true); + onToggleReviewed(key, checked === true); }} onClick={(e) => e.stopPropagation()} className="h-3.5 w-3.5" @@ -165,11 +168,16 @@ function TreeNode({ }; if (node.isDir) { + // Repo roots get a stronger label so the user sees the multi-repo grouping. + const fileCount = node.isRepoRoot ? countLeafFiles(node) : 0; return ( -
    +
    {expanded && node.children && (
    @@ -193,3 +211,12 @@ function TreeNode({ return ; } + +/** Counts leaf (file) nodes anywhere under the given node. Used for the + * "N files" badge on multi-repo repo-root headers. */ +function countLeafFiles(node: FileTreeNode): number { + if (!node.isDir) return 1; + let total = 0; + for (const child of node.children ?? []) total += countLeafFiles(child); + return total; +} diff --git a/apps/web/components/review/types.multi-repo.test.ts b/apps/web/components/review/types.multi-repo.test.ts new file mode 100644 index 0000000000..e1aa9362b8 --- /dev/null +++ b/apps/web/components/review/types.multi-repo.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { buildFileTree, reviewFileKey, splitReviewFileKey, type ReviewFile } from "./types"; + +const SEP = "\u0000"; + +const APP_PATH = "src/app.tsx"; + +function file(overrides: Partial): ReviewFile { + return { + path: APP_PATH, + diff: "", + status: "modified", + additions: 1, + deletions: 0, + staged: false, + source: "uncommitted", + ...overrides, + }; +} + +describe("buildFileTree — multi-repo", () => { + it("falls back to flat tree when files have no repository_name", () => { + const tree = buildFileTree([file({ path: "src/a.ts" }), file({ path: "src/b.ts" })]); + expect(tree[0].isRepoRoot).toBeUndefined(); + expect(tree[0].name).toContain("src"); + }); + + it("falls back to flat tree when only one repository is present", () => { + const tree = buildFileTree([ + file({ path: "src/a.ts", repository_name: "frontend", repository_id: "f" }), + file({ path: "src/b.ts", repository_name: "frontend", repository_id: "f" }), + ]); + expect(tree[0].isRepoRoot).toBeUndefined(); + }); + + it("groups by repo when 2+ distinct repositories are present", () => { + const tree = buildFileTree([ + file({ path: APP_PATH, repository_name: "frontend", repository_id: "f" }), + file({ path: "src/api.ts", repository_name: "frontend", repository_id: "f" }), + file({ path: "handlers/task.go", repository_name: "backend", repository_id: "b" }), + ]); + expect(tree).toHaveLength(2); + expect(tree.map((n) => n.name).sort()).toEqual(["backend", "frontend"]); + for (const root of tree) { + expect(root.isRepoRoot).toBe(true); + expect(root.repositoryId).toBeDefined(); + } + }); + + it("repo roots are not collapsed even when they have a single child", () => { + const tree = buildFileTree([ + file({ path: "lonely.ts", repository_name: "shared", repository_id: "s" }), + file({ path: "src/x.ts", repository_name: "main", repository_id: "m" }), + ]); + const shared = tree.find((n) => n.name === "shared"); + expect(shared).toBeDefined(); + expect(shared?.isRepoRoot).toBe(true); + expect(shared?.children).toHaveLength(1); + expect(shared?.children?.[0].name).toBe("lonely.ts"); + }); + + it("preserves file paths inside each repo (no leakage between repos)", () => { + const tree = buildFileTree([ + file({ path: "README.md", repository_name: "frontend", repository_id: "f" }), + file({ path: "README.md", repository_name: "backend", repository_id: "b" }), + ]); + const frontend = tree.find((n) => n.name === "frontend"); + const backend = tree.find((n) => n.name === "backend"); + expect(frontend?.children?.[0].name).toBe("README.md"); + expect(backend?.children?.[0].name).toBe("README.md"); + // File node back-refs point to the right repo. + expect(frontend?.children?.[0].file?.repository_id).toBe("f"); + expect(backend?.children?.[0].file?.repository_id).toBe("b"); + }); +}); + +// reviewFileKey + splitReviewFileKey are the dedup primitive for the +// multi-repo review state. They have to round-trip exactly, including +// path values that contain the path-separator characters most likely to +// appear in real user input. The NUL byte (FILE_KEY_SEP) is the one +// character that can never legitimately appear in a path or repo name on +// any supported filesystem, which is why it's the separator. +describe("reviewFileKey", () => { + it("returns the bare path when no repository_name is set", () => { + expect(reviewFileKey({ path: "README.md" })).toBe("README.md"); + expect(reviewFileKey({ path: "src/index.ts", repository_name: "" })).toBe("src/index.ts"); + }); + + it("joins repository_name and path with the NUL separator", () => { + expect(reviewFileKey({ path: "README.md", repository_name: "frontend" })).toBe( + `frontend${SEP}README.md`, + ); + }); + + it("preserves slashes, dots, and spaces in the path", () => { + expect(reviewFileKey({ path: "src/sub dir/file.ts", repository_name: "repo" })).toBe( + `repo${SEP}src/sub dir/file.ts`, + ); + }); + + it("disambiguates same-named files in different repos", () => { + const a = reviewFileKey({ path: "README.md", repository_name: "frontend" }); + const b = reviewFileKey({ path: "README.md", repository_name: "backend" }); + expect(a).not.toBe(b); + }); +}); + +describe("splitReviewFileKey", () => { + it("returns empty repositoryName for legacy bare-path keys", () => { + expect(splitReviewFileKey("README.md")).toEqual({ repositoryName: "", path: "README.md" }); + }); + + it("splits a composite key on the NUL separator", () => { + expect(splitReviewFileKey(`frontend${SEP}${APP_PATH}`)).toEqual({ + repositoryName: "frontend", + path: APP_PATH, + }); + }); + + it("treats a key with empty repository_name as a legacy bare-path key", () => { + // reviewFileKey({path, repository_name: ""}) returns the bare path, + // so split should mirror that — no NUL in input means repo is "". + expect(splitReviewFileKey(APP_PATH)).toEqual({ repositoryName: "", path: APP_PATH }); + }); + + it("round-trips through reviewFileKey", () => { + const cases = [ + { path: "README.md" }, + { path: "src/index.ts", repository_name: "frontend" }, + { path: "deep/nested/path/file.go", repository_name: "backend" }, + { path: "with spaces/file.md", repository_name: "shared" }, + // Repo names can themselves contain dashes and slashes. + { path: "x.ts", repository_name: "owner/repo-name" }, + ]; + for (const original of cases) { + const key = reviewFileKey(original); + const round = splitReviewFileKey(key); + expect(round.path).toBe(original.path); + expect(round.repositoryName).toBe(original.repository_name ?? ""); + } + }); +}); diff --git a/apps/web/components/review/types.ts b/apps/web/components/review/types.ts index c3d4e3b4d9..25917a60fe 100644 --- a/apps/web/components/review/types.ts +++ b/apps/web/components/review/types.ts @@ -9,8 +9,37 @@ export type ReviewFile = { staged: boolean; source: "uncommitted" | "committed" | "pr"; diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; + /** + * Repository this file belongs to. Set on multi-repo task changes so the + * file tree groups files under per-repo top-level nodes. Optional for + * single-repo tasks; the tree falls back to flat path-only grouping. + */ + repository_id?: string; + /** Human-readable repo name for tree node labels (e.g. "frontend"). */ + repository_name?: string; }; +/** + * Composite per-file key used by the review dialog's in-memory state + * (reviewed set, stale set, file refs, selected file, comment counts) and + * by the persisted `useSessionFileReviews` rows. The NUL separator is + * impossible to embed in a real path or repository name, so the key is + * always uniquely splittable. Single-repo files (no `repository_name`) + * keep the legacy bare-path key for backwards compatibility with existing + * `session_file_reviews` rows. + */ +const FILE_KEY_SEP = "\u0000"; + +export function reviewFileKey(file: { path: string; repository_name?: string }): string { + return file.repository_name ? `${file.repository_name}${FILE_KEY_SEP}${file.path}` : file.path; +} + +export function splitReviewFileKey(key: string): { repositoryName: string; path: string } { + const sep = key.indexOf(FILE_KEY_SEP); + if (sep < 0) return { repositoryName: "", path: key }; + return { repositoryName: key.slice(0, sep), path: key.slice(sep + 1) }; +} + export function diffSkipReasonLabel(reason?: string): string { switch (reason) { case "too_large": @@ -32,6 +61,14 @@ export type FileTreeNode = { isDir: boolean; children?: FileTreeNode[]; file?: ReviewFile; + /** + * True when this node represents a repository root in a multi-repo file + * tree. Repo roots are pinned (never collapsed into their first child) so + * the user always sees the repo grouping at the top level. + */ + isRepoRoot?: boolean; + /** Repository id this node represents (only set when isRepoRoot is true). */ + repositoryId?: string; }; /** @@ -61,8 +98,54 @@ export const hashDiff = djb2Hash; /** * Build a hierarchical file tree from flat file paths. * Collapses single-child directories (e.g., `src/components` as one node if `src` has no other children). + * + * Multi-repo: when files carry repository_name and 2+ distinct repos are + * present, the tree gets a top-level repo node per repository so the user + * sees per-repo grouping. Repo roots are pinned (never collapsed) and the + * file paths inside them stay relative to the repo root. */ export function buildFileTree(files: ReviewFile[]): FileTreeNode[] { + const repoNames = new Set(); + for (const f of files) { + if (f.repository_name) repoNames.add(f.repository_name); + } + const isMultiRepo = repoNames.size > 1; + + if (isMultiRepo) return buildMultiRepoTree(files); + return buildFlatTree(files); +} + +function buildMultiRepoTree(files: ReviewFile[]): FileTreeNode[] { + // Group by repo first, then build a sub-tree for each. + const byRepo = new Map(); + for (const f of files) { + const name = f.repository_name ?? "(unspecified)"; + const id = f.repository_id ?? name; + const key = id; + let entry = byRepo.get(key); + if (!entry) { + entry = { name, id, files: [] }; + byRepo.set(key, entry); + } + entry.files.push(f); + } + const repoRoots: FileTreeNode[] = []; + for (const [, entry] of byRepo) { + const subtree = buildFlatTree(entry.files); + repoRoots.push({ + name: entry.name, + path: `__repo__:${entry.id}`, + isDir: true, + isRepoRoot: true, + repositoryId: entry.id, + children: subtree, + }); + } + // Stable alphabetical order by repo name. + return repoRoots.sort((a, b) => a.name.localeCompare(b.name)); +} + +function buildFlatTree(files: ReviewFile[]): FileTreeNode[] { const root: FileTreeNode = { name: "", path: "", isDir: true, children: [] }; for (const file of files) { @@ -92,13 +175,20 @@ export function buildFileTree(files: ReviewFile[]): FileTreeNode[] { } } - // Collapse single-child directories + // Collapse single-child directories. Repo roots are never collapsed — + // they encode user-meaningful grouping that survives even when the repo + // has only one changed file. function collapse(node: FileTreeNode): FileTreeNode { if (!node.isDir || !node.children) return node; node.children = node.children.map(collapse); - if (node.children.length === 1 && node.children[0].isDir && node.name !== "") { + if ( + node.children.length === 1 && + node.children[0].isDir && + node.name !== "" && + !node.isRepoRoot + ) { const child = node.children[0]; return { ...child, diff --git a/apps/web/components/task-create-dialog-create-mode-selectors.tsx b/apps/web/components/task-create-dialog-create-mode-selectors.tsx new file mode 100644 index 0000000000..05264e4a05 --- /dev/null +++ b/apps/web/components/task-create-dialog-create-mode-selectors.tsx @@ -0,0 +1,51 @@ +"use client"; + +import type { AgentProfileOption } from "@/lib/state/slices"; +import type { DialogFormState } from "@/components/task-create-dialog-types"; +import { CreateEditSelectors } from "@/components/task-create-dialog-form-body"; +import { AgentSelector, ExecutorProfileSelector } from "@/components/task-create-dialog-selectors"; +import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; + +type CreateModeSelectorsProps = { + isTaskStarted: boolean; + agentProfileOptions: ReturnType; + executorProfileOptions: Array<{ + value: string; + label: string; + renderLabel?: () => React.ReactNode; + }>; + agentProfiles: AgentProfileOption[]; + agentProfilesLoading: boolean; + executorsLoading: boolean; + isCreatingSession: boolean; + fs: DialogFormState; + onAgentProfileChange: (v: string) => void; + onExecutorProfileChange: (v: string) => void; + workflowAgentLocked: boolean; +}; + +/** + * Create/edit-mode form body section: agent + executor profile selectors. + * Repo, branch and fresh-branch toggle live in the chip row above the + * description (RepoChipsRow). + */ +export function CreateModeSelectors(props: CreateModeSelectorsProps) { + return ( + + ); +} diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 66845c7b84..033a5dea0f 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -1,14 +1,13 @@ "use client"; import { useEffect } from "react"; -import type { Repository, Executor, Branch } from "@/lib/types/http"; +import type { Repository, Executor } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import { DEFAULT_LOCAL_EXECUTOR_TYPE } from "@/lib/utils"; import { useToast } from "@/components/toast-provider"; import { discoverRepositoriesAction, getLocalRepositoryStatusAction, - listLocalRepositoryBranchesAction, } from "@/app/actions/workspaces"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; @@ -19,7 +18,6 @@ import type { StoreSelections, TaskCreateEffectsArgs, } from "@/components/task-create-dialog-types"; -import { autoSelectBranch } from "@/components/task-create-dialog-helpers"; export function useWorkflowAgentProfileEffect( fs: DialogFormState, @@ -80,25 +78,28 @@ export function useRepositoryAutoSelectEffect( workspaceId: string | null, repositories: Repository[], ) { - const { repositoryId, selectedLocalRepo, useGitHubUrl, setRepositoryId } = fs; + // On open, ensure there's always at least one chip rendered: prefer the + // user's last-used repo (or the workspace's only repo) so the chip lands + // pre-filled, but fall back to an empty row so the picker is visible + // instead of just the "+" button. URL mode is excluded — that flow swaps + // the chip row for a URL input. + const { repositories: rows, useGitHubUrl, setRepositories } = fs; useEffect(() => { - if (!open || !workspaceId || repositoryId || selectedLocalRepo || useGitHubUrl) return; + if (!open || !workspaceId || useGitHubUrl) return; + if (rows.length > 0) return; const lastUsedRepoId = getLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, null); + let pickId: string | null = null; if (lastUsedRepoId && repositories.some((r: Repository) => r.id === lastUsedRepoId)) { - void Promise.resolve().then(() => setRepositoryId(lastUsedRepoId)); - return; + pickId = lastUsedRepoId; + } else if (repositories.length === 1) { + pickId = repositories[0].id; } - if (repositories.length === 1) - void Promise.resolve().then(() => setRepositoryId(repositories[0].id)); - }, [ - open, - repositories, - repositoryId, - selectedLocalRepo, - useGitHubUrl, - workspaceId, - setRepositoryId, - ]); + void Promise.resolve().then(() => + setRepositories([ + pickId ? { key: "row-0", repositoryId: pickId, branch: "" } : { key: "row-0", branch: "" }, + ]), + ); + }, [open, repositories, rows, useGitHubUrl, workspaceId, setRepositories]); } export function useDiscoverReposEffect( @@ -148,71 +149,31 @@ export function useDiscoverReposEffect( ]); } -export function useLocalBranchesEffect( - fs: DialogFormState, - open: boolean, - workspaceId: string | null, - toast: ReturnType["toast"], -) { - const { selectedLocalRepo, setLocalBranches, setLocalBranchesLoading, setCurrentLocalBranch } = - fs; - useEffect(() => { - if (!open || !workspaceId || !selectedLocalRepo) return; - const repoPath = selectedLocalRepo.path; - void Promise.resolve() - .then(() => setLocalBranchesLoading(true)) - .then(() => listLocalRepositoryBranchesAction(workspaceId, repoPath)) - .then((r) => { - setLocalBranches(r.branches); - setCurrentLocalBranch(r.current_branch ?? ""); - }) - .catch((e) => { - toast({ - title: "Failed to load branches", - description: e instanceof Error ? e.message : "Request failed", - variant: "error", - }); - setLocalBranches([]); - setCurrentLocalBranch(""); - }) - .finally(() => { - setLocalBranchesLoading(false); - }); - }, [ - open, - selectedLocalRepo, - toast, - workspaceId, - setLocalBranches, - setLocalBranchesLoading, - setCurrentLocalBranch, - ]); -} - -/** - * Resolves the absolute repo path for whichever repository the user has selected - * (workspace-attached or discovered) and fetches its currently-checked-out branch. - * Used by the disabled branch-selector placeholder so users see exactly which - * branch their local clone is on. - */ +// Per-row branch listing now lives in the chip itself via useBranches, so the +// old useLocalBranchesEffect is gone. +// +// useCurrentLocalBranchEffect still earns its keep — the fresh-branch +// consent flow needs to know which branch the on-disk clone is currently on, +// and that's only meaningful for a single-row local-executor task. For multi- +// repo tasks fresh-branch is hidden in the UI, so we only resolve a path +// when there's exactly one row. export function useCurrentLocalBranchEffect( fs: DialogFormState, open: boolean, workspaceId: string | null, repositories: Repository[], ) { - const { repositoryId, selectedLocalRepo, useGitHubUrl, setCurrentLocalBranch } = fs; + const { repositories: rows, useGitHubUrl, setCurrentLocalBranch } = fs; useEffect(() => { - if (!open || !workspaceId || useGitHubUrl) { + if (!open || !workspaceId || useGitHubUrl || rows.length !== 1) { setCurrentLocalBranch(""); return; } - let path = ""; - if (selectedLocalRepo) { - path = selectedLocalRepo.path; - } else if (repositoryId) { - const repo = repositories.find((r: Repository) => r.id === repositoryId); - if (repo?.local_path) path = repo.local_path; + const row = rows[0]; + let path = row.localPath ?? ""; + if (!path && row.repositoryId) { + const repo = repositories.find((r: Repository) => r.id === row.repositoryId); + path = repo?.local_path ?? ""; } if (!path) { setCurrentLocalBranch(""); @@ -229,15 +190,7 @@ export function useCurrentLocalBranchEffect( return () => { cancelled = true; }; - }, [ - open, - workspaceId, - useGitHubUrl, - repositoryId, - selectedLocalRepo, - repositories, - setCurrentLocalBranch, - ]); + }, [open, workspaceId, useGitHubUrl, rows, repositories, setCurrentLocalBranch]); } export function useDefaultSelectionsEffect( @@ -328,37 +281,57 @@ export function useDefaultSelectionsEffect( } } }, [executorProfileId, executors, setExecutorId]); + + // Multi-repo guard: when 2+ repos are selected, only worktree profiles can + // run the task (Docker / Sprites / standalone don't yet provision sibling + // repos under one task root). If the current profile is non-worktree, swap + // to a worktree profile — preferring the last-used worktree, otherwise the + // first one available. Single-repo selections leave the profile alone. + useEffect(() => { + if (!open || !executorProfileId || executors.length === 0) return; + const namedRepos = fs.repositories.filter((r) => r.repositoryId || r.localPath); + if (namedRepos.length <= 1) return; + const profileToType = new Map(); + const worktreeProfileIds: string[] = []; + for (const e of executors) { + for (const p of e.profiles ?? []) { + const type = p.executor_type ?? e.type; + profileToType.set(p.id, type); + if (type === "worktree") worktreeProfileIds.push(p.id); + } + } + if (worktreeProfileIds.length === 0) return; + if (profileToType.get(executorProfileId) === "worktree") return; + const lastId = getLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, null); + const pick = lastId && worktreeProfileIds.includes(lastId) ? lastId : worktreeProfileIds[0]; + void Promise.resolve().then(() => setExecutorProfileId(pick)); + }, [open, executorProfileId, executors, fs.repositories, setExecutorProfileId]); } -export function useBranchAutoSelectEffect(fs: DialogFormState, branches: Branch[]) { - const { repositoryId, branch, localBranches, githubBranches, useGitHubUrl, setBranch } = fs; +/** + * Auto-selects a sensible branch in the GitHub URL flow. Per-repo (workspace + * or discovered) branch auto-select happens inside RepoChip when a row's + * branches load — that keeps the per-row state confined to the chip. + */ +export function useBranchAutoSelectEffect(fs: DialogFormState) { + const { githubBranch, githubBranches, useGitHubUrl, setGitHubBranch, githubPrHeadBranch } = fs; useEffect(() => { - if (!repositoryId || branch) return; - autoSelectBranch(branches, setBranch); - }, [branch, branches, repositoryId, setBranch]); - useEffect(() => { - if (repositoryId || localBranches.length === 0 || branch) return; - autoSelectBranch(localBranches, setBranch); - }, [branch, localBranches, repositoryId, setBranch]); - const { githubPrHeadBranch } = fs; - useEffect(() => { - if (!useGitHubUrl || githubBranches.length === 0 || branch) return; - // If a PR URL was detected, prefer the PR's head branch. + if (!useGitHubUrl || githubBranches.length === 0 || githubBranch) return; if (githubPrHeadBranch) { const prBranch = githubBranches.find((b) => b.name === githubPrHeadBranch); if (prBranch) { - setBranch(prBranch.name); + setGitHubBranch(prBranch.name); return; } } - // GitHub URL branches are referenced by name only (no remote prefix). + // GitHub URL branches are referenced by name only (no remote prefix); // selectPreferredBranch expects origin-prefixed remotes, so pick directly. const preferred = githubBranches.find((b) => b.name === "main") ?? githubBranches.find((b) => b.name === "master") ?? githubBranches[0]; - if (preferred) setBranch(preferred.name); - }, [branch, githubBranches, useGitHubUrl, setBranch, githubPrHeadBranch]); + if (preferred) setGitHubBranch(preferred.name); + }, [githubBranch, githubBranches, useGitHubUrl, setGitHubBranch, githubPrHeadBranch]); } /** Parse a GitHub URL to extract owner, repo, and optional PR number. Returns null if invalid. */ @@ -468,14 +441,13 @@ export function useGitHubUrlBranchesEffect(fs: DialogFormState, open: boolean) { } export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) { - const { open, workspaceId, workflowId, repositories, repositoriesLoading, branches } = args; + const { open, workspaceId, workflowId, repositories, repositoriesLoading } = args; const { agentProfiles, executors, workspaceDefaults, toast, workflows } = args; useWorkflowStepsEffect(fs, workflowId); useWorkflowAgentProfileEffect(fs, workflows, agentProfiles); useRepositoryAutoSelectEffect(fs, open, workspaceId, repositories); useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast); - useBranchAutoSelectEffect(fs, branches); - useLocalBranchesEffect(fs, open, workspaceId, toast); + useBranchAutoSelectEffect(fs); useCurrentLocalBranchEffect(fs, open, workspaceId, repositories); useDefaultSelectionsEffect(fs, open, { agentProfiles, executors, workspaceDefaults }, workflows); useGitHubUrlBranchesEffect(fs, open); diff --git a/apps/web/components/task-create-dialog-footer.test.ts b/apps/web/components/task-create-dialog-footer.test.ts index 8bcc92a00f..69b4395f97 100644 --- a/apps/web/components/task-create-dialog-footer.test.ts +++ b/apps/web/components/task-create-dialog-footer.test.ts @@ -29,7 +29,7 @@ function makeProps( hasTitle: true, hasDescription: true, hasRepositorySelection: true, - branch: "main", + hasAllBranches: true, agentProfileId: "agent-1", workspaceId: "ws-1", effectiveWorkflowId: "wf-1", @@ -69,7 +69,9 @@ describe("computeDisabledReason (start-task)", () => { }); it("flags missing branch", () => { - expect(computeDisabledReason(makeProps({ branch: "" }), KIND_START)).toBe(REASON_BRANCH); + expect(computeDisabledReason(makeProps({ hasAllBranches: false }), KIND_START)).toBe( + REASON_BRANCH, + ); }); it("flags missing workspace in create mode", () => { @@ -157,7 +159,7 @@ describe("computeDisabledReason (default)", () => { isSessionMode: true, hasTitle: false, hasRepositorySelection: false, - branch: "", + hasAllBranches: false, }), KIND_DEFAULT, ), diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index a65a4967c3..2b78f929f8 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -216,7 +216,12 @@ export type TaskCreateDialogFooterProps = { hasTitle: boolean; hasDescription: boolean; hasRepositorySelection: boolean; - branch: string; + /** + * True when every selected repo has a base branch picked (and the URL flow's + * branch, if active). Was previously `branch: string` for the single-repo + * primary; now aggregated upstream because each row has its own branch. + */ + hasAllBranches: boolean; agentProfileId: string; workspaceId: string | null; effectiveWorkflowId: string | null; @@ -245,7 +250,7 @@ function computeBaseDisabled(props: TaskCreateDialogFooterProps) { props.isCreatingTask || !props.hasTitle || !props.hasRepositorySelection || - !props.branch || + !props.hasAllBranches || missingCtx ); } @@ -263,7 +268,7 @@ export const REASON_DESCRIPTION = "Add a session description"; function baseReason(props: TaskCreateDialogFooterProps): string | null { if (!props.hasTitle) return REASON_TITLE; if (!props.hasRepositorySelection) return REASON_REPO; - if (!props.branch) return REASON_BRANCH; + if (!props.hasAllBranches) return REASON_BRANCH; if (props.isCreateMode && !props.workspaceId) return REASON_WORKSPACE; if (props.isCreateMode && !props.effectiveWorkflowId) return REASON_WORKFLOW; return null; diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index aa03565f9d..98b596da0f 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -9,10 +9,6 @@ import { AgentLogo } from "@/components/agent-logo"; import type { DialogFormState } from "@/components/task-create-dialog-types"; import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; import { TaskFormInputs } from "@/components/task-create-dialog-selectors"; -import { - FreshBranchToggle, - computeBranchPlaceholder, -} from "@/components/task-create-dialog-fresh-branch"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; @@ -24,15 +20,6 @@ type SelectorOption = { type CreateEditSelectorsProps = { isTaskStarted: boolean; - hasRepositorySelection: boolean; - branchOptions: SelectorOption[]; - branch: string; - onBranchChange: (value: string) => void; - branchesLoading: boolean; - onRefreshBranches?: () => void; - branchesFetchedAt?: string; - branchesFetchError?: string; - localBranchesLoading: boolean; agentProfiles: AgentProfileOption[]; agentProfilesLoading: boolean; agentProfileOptions: SelectorOption[]; @@ -47,20 +34,6 @@ type CreateEditSelectorsProps = { executorProfileId: string; onExecutorProfileChange: (value: string) => void; executorsLoading: boolean; - BranchSelectorComponent: React.ComponentType<{ - options: SelectorOption[]; - value: string; - onValueChange: (value: string) => void; - disabled: boolean; - placeholder: string; - searchPlaceholder: string; - emptyMessage: string; - onRefresh?: () => void; - refreshing?: boolean; - fetchedAt?: string; - fetchError?: string; - loading?: boolean; - }>; AgentSelectorComponent: React.ComponentType<{ options: SelectorOption[]; value: string; @@ -77,13 +50,7 @@ type CreateEditSelectorsProps = { placeholder: string; triggerClassName?: string; }>; - isLocalExecutor: boolean; - useGitHubUrl: boolean; workflowAgentLocked: boolean; - freshBranchEnabled: boolean; - onToggleFreshBranch: (enabled: boolean) => void; - currentLocalBranch: string; - branchLocked?: boolean; }; type AgentColumnProps = Pick< @@ -135,97 +102,23 @@ function AgentColumn({ ); } -function computeBranchDisabled(args: { - branchLocked?: boolean; - lockedToCurrentBranch: boolean; - hasRepositorySelection: boolean; - branchesLoading: boolean; - localBranchesLoading: boolean; - optionCount: number; -}): boolean { - if (args.branchLocked) return true; - if (args.lockedToCurrentBranch) return true; - if (!args.hasRepositorySelection) return true; - if (args.branchesLoading || args.localBranchesLoading) return true; - return args.optionCount === 0; -} - export const CreateEditSelectors = memo(function CreateEditSelectors( props: CreateEditSelectorsProps, ) { if (props.isTaskStarted) return null; - const { - hasRepositorySelection, - branchOptions, - branch, - onBranchChange, - branchesLoading, - onRefreshBranches, - branchesFetchedAt, - branchesFetchError, - localBranchesLoading, executorProfileOptions, executorProfileId, onExecutorProfileChange, executorsLoading, - isLocalExecutor, - useGitHubUrl, - freshBranchEnabled, - onToggleFreshBranch, - currentLocalBranch, - BranchSelectorComponent, ExecutorProfileSelectorComponent, - branchLocked, } = props; - const isLocalWithoutGitHubUrl = isLocalExecutor && !useGitHubUrl; - // When `branchLocked` is true the caller pinned a specific branch (e.g. - // Improve Kandev) — suppress the local-executor "current branch" shortcut and - // the FreshBranchToggle so users can't drift away from the pinned value. - const lockedToCurrentBranch = !branchLocked && isLocalWithoutGitHubUrl && !freshBranchEnabled; - const branchPlaceholder = computeBranchPlaceholder({ - lockedToCurrentBranch, - currentLocalBranch, - hasRepositorySelection, - loading: branchesLoading || localBranchesLoading, - optionCount: branchOptions.length, - }); - const branchDisabled = computeBranchDisabled({ - branchLocked, - lockedToCurrentBranch, - hasRepositorySelection, - branchesLoading, - localBranchesLoading, - optionCount: branchOptions.length, - }); - // When the local executor locks to the current branch, ignore any branch - // value the user picked under a different executor — the placeholder - // should reflect the actual checked-out branch instead. - const branchValue = lockedToCurrentBranch ? "" : branch; + // Branch + repo selection (and the FreshBranchToggle, which is per-task + // branch strategy) live in the chip row above the description; this row + // carries only agent and executor profile selectors. return ( -
    -
    -
    - -
    - {isLocalWithoutGitHubUrl && !branchLocked && ( - - )} -
    +
    diff --git a/apps/web/components/task-create-dialog-handlers.ts b/apps/web/components/task-create-dialog-handlers.ts index 88ed8dff9a..4716c08efd 100644 --- a/apps/web/components/task-create-dialog-handlers.ts +++ b/apps/web/components/task-create-dialog-handlers.ts @@ -2,52 +2,59 @@ import { useCallback } from "react"; import type { Repository } from "@/lib/types/http"; +import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types"; import { setLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; -import type { DialogFormState } from "@/components/task-create-dialog-types"; +/** + * Centralizes form-field change handlers for the task-create dialog. + * + * The dialog stores all repos in a single `fs.repositories` list (no + * "primary vs extras" split), so per-row handlers are uniform: changing + * a repo on row N is the same op whether N==0 or N==5. + * + * Fresh-branch (local-executor opt-in: discard local changes and start on + * a new branch) is a separate concern that lives alongside. + */ function clearFreshBranch(fs: DialogFormState) { fs.setFreshBranchEnabled(false); fs.setCurrentLocalBranch(""); } function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) { - const handleSelectLocalRepository = useCallback( - (path: string) => { - fs.setDiscoveredRepoPath(path); - fs.setSelectedLocalRepo(fs.discoveredRepositories.find((r) => r.path === path) ?? null); - fs.setRepositoryId(""); - fs.setBranch(""); - fs.setLocalBranches([]); + /** + * Resolves a picker value into the right shape for a row: + * - If it matches a workspace repo id → `{ repositoryId, localPath: undefined }`. + * - Otherwise treat as a discovered on-machine path → `{ localPath, repositoryId: undefined }`. + * The branch is reset because the previous branch may not exist on the new repo. + */ + const handleRowRepositoryChange = useCallback( + (key: string, value: string) => { + const isWorkspaceRepo = repositories.some((r: Repository) => r.id === value); + const patch: Partial = isWorkspaceRepo + ? { repositoryId: value, localPath: undefined, branch: "" } + : { repositoryId: undefined, localPath: value, branch: "" }; + fs.updateRepository(key, patch); + if (isWorkspaceRepo) setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value); + // Switching the repo invalidates whatever local-status the fresh-branch + // panel had cached. clearFreshBranch(fs); }, + [repositories, fs], + ); + + const handleRowBranchChange = useCallback( + (key: string, value: string) => { + fs.updateRepository(key, { branch: value }); + setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value); + }, [fs], ); - const handleRepositoryChange = useCallback( - (value: string) => { - if (repositories.find((r: Repository) => r.id === value)) { - fs.setRepositoryId(value); - setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value); - fs.setDiscoveredRepoPath(""); - fs.setSelectedLocalRepo(null); - fs.setLocalBranches([]); - fs.setBranch(""); - fs.setUseGitHubUrl(false); - fs.setGitHubUrl(""); - fs.setGitHubBranches([]); - clearFreshBranch(fs); - return; - } - handleSelectLocalRepository(value); - }, - [repositories, fs, handleSelectLocalRepository], - ); - - return { handleRepositoryChange, handleSelectLocalRepository }; + return { handleRowRepositoryChange, handleRowBranchChange }; } -function useBranchAndProfileHandlers(fs: DialogFormState) { +function useProfileAndNameHandlers(fs: DialogFormState) { const handleAgentProfileChange = useCallback( (value: string) => { fs.setAgentProfileId(value); @@ -69,9 +76,9 @@ function useBranchAndProfileHandlers(fs: DialogFormState) { }, [fs], ); - const handleBranchChange = useCallback( + const handleGitHubBranchChange = useCallback( (value: string) => { - fs.setBranch(value); + fs.setGitHubBranch(value); setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value); }, [fs], @@ -84,37 +91,36 @@ function useBranchAndProfileHandlers(fs: DialogFormState) { handleAgentProfileChange, handleExecutorProfileChange, handleTaskNameChange, - handleBranchChange, + handleGitHubBranchChange, handleWorkflowChange, }; } function useGitHubAndFreshBranchHandlers(fs: DialogFormState) { + /** + * Toggles between "repo chips" mode and "GitHub URL" mode. URL mode + * replaces the chip row with a single URL input; flipping back restores + * the chip flow with whatever rows were already there. + */ const handleToggleGitHubUrl = useCallback(() => { const next = !fs.useGitHubUrl; fs.setUseGitHubUrl(next); - if (next) { - fs.setRepositoryId(""); - fs.setDiscoveredRepoPath(""); - fs.setSelectedLocalRepo(null); - fs.setLocalBranches([]); - } else { + if (!next) { fs.setGitHubUrl(""); fs.setGitHubBranches([]); fs.setGitHubUrlError(null); fs.setGitHubPrHeadBranch(null); } - fs.setBranch(""); + fs.setGitHubBranch(""); clearFreshBranch(fs); }, [fs]); const handleToggleFreshBranch = useCallback( (enabled: boolean) => { fs.setFreshBranchEnabled(enabled); - // Always clear the branch so we don't carry a value over from a - // different executor (e.g. "develop" picked under worktree) into the - // newly-enabled fresh-branch picker. - fs.setBranch(""); + // Clearing fs.repositories[].branch on toggle would force a re-pick from + // the per-row branch list; for simplicity leave whatever the user picked. + // The submit path re-validates anyway. }, [fs], ); @@ -122,7 +128,7 @@ function useGitHubAndFreshBranchHandlers(fs: DialogFormState) { const handleGitHubUrlChange = useCallback( (value: string) => { fs.setGitHubUrl(value); - fs.setBranch(""); + fs.setGitHubBranch(""); fs.setGitHubBranches([]); fs.setGitHubUrlError(null); fs.setGitHubPrHeadBranch(null); @@ -139,10 +145,10 @@ function useGitHubAndFreshBranchHandlers(fs: DialogFormState) { export function useDialogHandlers(fs: DialogFormState, repositories: Repository[]) { const repo = useRepositoryHandlers(fs, repositories); - const profile = useBranchAndProfileHandlers(fs); + const profile = useProfileAndNameHandlers(fs); const gh = useGitHubAndFreshBranchHandlers(fs); return { - handleRepositoryChange: repo.handleRepositoryChange, + ...repo, ...profile, ...gh, }; diff --git a/apps/web/components/task-create-dialog-header.tsx b/apps/web/components/task-create-dialog-header.tsx index 210750e4ec..36f02d777d 100644 --- a/apps/web/components/task-create-dialog-header.tsx +++ b/apps/web/components/task-create-dialog-header.tsx @@ -1,165 +1,28 @@ "use client"; import { DialogTitle } from "@kandev/ui/dialog"; -import { RepositorySelector, InlineTaskName } from "@/components/task-create-dialog-selectors"; -import type { useRepositoryOptions } from "@/components/task-create-dialog-options"; - -function getRepositoryPlaceholder( - workspaceId: string | null, - repositoriesLoading: boolean, - discoverReposLoading: boolean, -) { - if (!workspaceId) return "Select workspace first"; - if (repositoriesLoading || discoverReposLoading) return "Loading..."; - return "Select repository"; -} export type DialogHeaderContentProps = { isCreateMode: boolean; isEditMode: boolean; - isTaskStarted: boolean; sessionRepoName?: string; initialTitle?: string; - taskName: string; - repositoryId: string; - discoveredRepoPath: string; - workspaceId: string | null; - repositoriesLoading: boolean; - discoverReposLoading: boolean; - headerRepositoryOptions: ReturnType["headerRepositoryOptions"]; - onRepositoryChange: (v: string) => void; - onTaskNameChange: (v: string) => void; - useGitHubUrl: boolean; - githubUrl: string; - githubUrlError: string | null; - onToggleGitHubUrl: () => void; - onGitHubUrlChange: (v: string) => void; - repositoryLocked?: boolean; }; -type RepoSourceInputProps = Pick< - DialogHeaderContentProps, - | "useGitHubUrl" - | "githubUrl" - | "githubUrlError" - | "onGitHubUrlChange" - | "isTaskStarted" - | "headerRepositoryOptions" - | "repositoryId" - | "discoveredRepoPath" - | "onRepositoryChange" - | "workspaceId" - | "repositoriesLoading" - | "discoverReposLoading" - | "repositoryLocked" ->; - -function GhUrlInput({ - githubUrl, - githubUrlError, - onGitHubUrlChange, - isTaskStarted, -}: Pick< - RepoSourceInputProps, - "githubUrl" | "githubUrlError" | "onGitHubUrlChange" | "isTaskStarted" ->): React.ReactNode { - return ( -
    - onGitHubUrlChange(e.target.value)} - placeholder="github.com/owner/repo" - data-testid="github-url-input" - aria-label="GitHub repository URL" - aria-invalid={!!githubUrlError} - aria-describedby={githubUrlError ? "github-url-error" : undefined} - size={Math.max((githubUrl || "").length + 1, "github.com/owner/repo".length)} - className={`bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 h-7 rounded-md px-2 hover:bg-muted focus:bg-muted transition-colors placeholder:text-muted-foreground ${githubUrlError ? "text-destructive" : ""}`} - disabled={isTaskStarted} - autoFocus - /> - {githubUrlError && ( - - )} -
    - ); -} - -function resolveSelectorPlaceholder(p: RepoSourceInputProps): string { - if (p.repositoryLocked && !(p.repositoryId || p.discoveredRepoPath)) { - return "Preparing kandev repository..."; - } - return getRepositoryPlaceholder(p.workspaceId, p.repositoriesLoading, p.discoverReposLoading); -} - -function RepoSourceInput(p: RepoSourceInputProps): React.ReactNode { - // When the dialog locks the repository (e.g. Improve Kandev pinning kdlbs/kandev), - // never expose the editable GitHub URL input — even if useGitHubUrl is on. - if (p.useGitHubUrl && !p.repositoryLocked) return ; - const isDisabled = - p.repositoryLocked || - p.isTaskStarted || - !p.workspaceId || - p.repositoriesLoading || - p.discoverReposLoading; - const emptyMessage = - p.repositoriesLoading || p.discoverReposLoading - ? "Loading repositories..." - : "No repositories found."; - return ( - - ); -} - -export function DialogHeaderContent(props: DialogHeaderContentProps) { - const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props; - const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl, repositoryLocked } = props; - +/** + * Header for the task-create dialog. In create/edit mode the header is + * intentionally minimal — the dialog itself signals "create" and the + * repo + branch chips and task-name input live in the body. Session mode + * shows a breadcrumb (repo / task / new session). + */ +export function DialogHeaderContent({ + isCreateMode, + isEditMode, + sessionRepoName, + initialTitle, +}: DialogHeaderContentProps) { if (isCreateMode || isEditMode) { - return ( - <> - -
    - - / - -
    -
    - {!isTaskStarted && !repositoryLocked && ( -
    - -
    - )} - - ); + return {isEditMode ? "Edit task" : "New task"}; } return ( diff --git a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts new file mode 100644 index 0000000000..95321d64fe --- /dev/null +++ b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { buildRepositoriesPayload } from "./task-create-dialog-helpers"; + +describe("buildRepositoriesPayload — unified rows", () => { + it("maps each row in order, dropping empty ones silently", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [ + { key: "r0", repositoryId: "repo-front", branch: "main" }, + { key: "r1", repositoryId: "repo-back", branch: "develop" }, + { key: "r2", branch: "" }, // no repo picked yet — dropped + { key: "r3", repositoryId: "repo-shared", branch: "" }, + ], + discoveredRepositories: [], + }); + expect(payload).toEqual([ + { repository_id: "repo-front", base_branch: "main" }, + { repository_id: "repo-back", base_branch: "develop" }, + { repository_id: "repo-shared", base_branch: undefined }, + ]); + }); + + it("emits local_path + default_branch for discovered (on-machine) rows", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [ + { key: "r0", localPath: "/home/me/projects/local-project", branch: "trunk" }, + { key: "r1", repositoryId: "repo-back", branch: "main" }, + ], + discoveredRepositories: [ + { path: "/home/me/projects/local-project", default_branch: "trunk" }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, + }); + expect(payload).toEqual([ + { + repository_id: "", + base_branch: "trunk", + local_path: "/home/me/projects/local-project", + default_branch: "trunk", + }, + { repository_id: "repo-back", base_branch: "main" }, + ]); + }); + + it("URL mode produces a single github_url entry and ignores the rows", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: true, + githubUrl: "github.com/owner/repo", + githubBranch: "feature-x", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "ignored", branch: "ignored" }], + discoveredRepositories: [], + }); + expect(payload).toEqual([ + { + repository_id: "", + base_branch: "feature-x", + checkout_branch: undefined, + github_url: "github.com/owner/repo", + }, + ]); + }); + + it("single-row workspace repo: payload mirrors the row", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-only", branch: "main" }], + discoveredRepositories: [], + }); + expect(payload).toEqual([{ repository_id: "repo-only", base_branch: "main" }]); + }); +}); diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index f274d3fbde..51cf63fd97 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -2,7 +2,7 @@ import type { useRouter } from "next/navigation"; import type { Task, Branch, LocalRepository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { AppState } from "@/lib/state/store"; -import type { StepType } from "@/components/task-create-dialog-types"; +import type { StepType, TaskRepoRow } from "@/components/task-create-dialog-types"; import { selectPreferredBranch } from "@/lib/utils"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; @@ -138,35 +138,51 @@ export function validateCreateInputs(inputs: { trimmedTitle: string; workspaceId: string | null; effectiveWorkflowId: string | null; - repositoryId: string; - selectedLocalRepo: LocalRepository | null; + /** Unified repos list. The form is valid if any row has a repo set OR URL mode is filled. */ + repositories: TaskRepoRow[]; githubUrl?: string; agentProfileId: string; }): boolean { + const hasRepo = + inputs.repositories.some((r) => r.repositoryId || r.localPath) || + Boolean(inputs.githubUrl?.trim()); return Boolean( inputs.trimmedTitle && inputs.workspaceId && inputs.effectiveWorkflowId && inputs.agentProfileId && - (inputs.repositoryId || inputs.selectedLocalRepo || inputs.githubUrl?.trim()), + hasRepo, ); } -/** Builds the repositories payload for task creation from the current form state. */ +/** + * Builds the repositories payload for task creation from the unified list. + * + * - URL mode produces a single entry with `github_url`. + * - Otherwise each row maps to either a workspace `repository_id` or a + * discovered `local_path`. Empty rows are dropped silently so a user + * can leave an unfinished chip without blocking submit; duplicate + * detection happens on the backend. + */ export function buildRepositoriesPayload(opts: { useGitHubUrl: boolean; githubUrl: string; - branch: string; + githubBranch: string; githubPrHeadBranch: string | null; - repositoryId: string; - selectedLocalRepo: LocalRepository | null; + repositories: TaskRepoRow[]; + /** Used to look up `default_branch` for `localPath` rows. */ + discoveredRepositories: LocalRepository[]; + /** + * Optional fresh-branch metadata. The UI gates this to single-row + local + * executor; when present we apply it to every row (which is at most one). + */ freshBranch?: { confirmDiscard: boolean; consentedDirtyFiles: string[] }; }): NonNullable { if (opts.useGitHubUrl && opts.githubUrl) { return [ { repository_id: "", - base_branch: opts.branch || undefined, + base_branch: opts.githubBranch || undefined, checkout_branch: opts.githubPrHeadBranch || undefined, github_url: opts.githubUrl.trim(), }, @@ -179,26 +195,23 @@ export function buildRepositoriesPayload(opts: { consented_dirty_files: opts.freshBranch.consentedDirtyFiles, } : {}; - if (opts.repositoryId) { - return [ - { - repository_id: opts.repositoryId, - base_branch: opts.branch || undefined, - checkout_branch: opts.githubPrHeadBranch || undefined, - ...fresh, - }, - ]; - } - if (opts.selectedLocalRepo) { - return [ - { + return opts.repositories + .filter((row) => row.repositoryId || row.localPath) + .map((row) => { + if (row.repositoryId) { + return { + repository_id: row.repositoryId, + base_branch: row.branch || undefined, + ...fresh, + }; + } + const local = opts.discoveredRepositories.find((d) => d.path === row.localPath); + return { repository_id: "", - base_branch: opts.branch || undefined, - local_path: opts.selectedLocalRepo.path, - default_branch: opts.selectedLocalRepo.default_branch || undefined, + base_branch: row.branch || undefined, + local_path: row.localPath, + default_branch: local?.default_branch || undefined, ...fresh, - }, - ]; - } - return []; + }; + }); } diff --git a/apps/web/components/task-create-dialog-locked-fields.ts b/apps/web/components/task-create-dialog-locked-fields.ts index 5c305b1439..737ffd2434 100644 --- a/apps/web/components/task-create-dialog-locked-fields.ts +++ b/apps/web/components/task-create-dialog-locked-fields.ts @@ -8,16 +8,15 @@ import type { type LockedFieldFormState = Pick< DialogFormState, - | "selectedWorkflowId" - | "setSelectedWorkflowId" - | "repositoryId" - | "setRepositoryId" - | "branch" - | "setBranch" + "selectedWorkflowId" | "setSelectedWorkflowId" | "repositories" | "setRepositories" >; -// Pushes late-arriving locked field values into form state when async feature -// wrappers resolve them after the dialog is already open. +/** + * Pushes late-arriving locked field values into form state when async feature + * wrappers resolve them after the dialog is already open. Multi-repo: locks + * are expressed as the first chip in `fs.repositories[]`; the dialog's + * single-repo locking flow (Improve Kandev) overwrites `repositories[0]`. + */ export function useLockedFieldSync( open: boolean, workflowId: string | null, @@ -31,12 +30,10 @@ export function useLockedFieldSync( if (workflowId && workflowId !== fs.selectedWorkflowId) { fs.setSelectedWorkflowId(workflowId); } - if (repoId && repoId !== fs.repositoryId) { - fs.setRepositoryId(repoId); - } - if (branch && branch !== fs.branch) { - fs.setBranch(branch); - } + if (!repoId) return; + const current = fs.repositories[0]; + if (current?.repositoryId === repoId && current?.branch === (branch ?? "")) return; + fs.setRepositories([{ key: "row-0", repositoryId: repoId, branch: branch ?? "" }]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, workflowId, repoId, branch]); } diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index 40713c1e45..4650952699 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -21,6 +21,8 @@ type OptionItem = { value: string; label: string; renderLabel: () => React.ReactNode; + disabled?: boolean; + disabledReason?: string; }; export function useRepositoryOptions( @@ -206,17 +208,31 @@ export type ExecutorProfileOptionItem = OptionItem & { executorName?: string; }; +export type ExecutorProfileOptionsConfig = { + /** + * Returns a tooltip string when the given profile should render disabled. + * Used by the kanban task-create dialog to gate non-worktree executors + * (only worktree-based execution is currently supported there). + */ + disabledReasonFor?: (profile: ExecutorProfile) => string | null; +}; + export function useExecutorProfileOptions( allProfiles: ExecutorProfile[], + config?: ExecutorProfileOptionsConfig, ): ExecutorProfileOptionItem[] { + const disabledReasonFor = config?.disabledReasonFor; return useMemo(() => { return allProfiles.map((profile) => { const Icon = getExecutorIcon(profile.executor_type ?? "local"); + const disabledReason = disabledReasonFor?.(profile) ?? null; return { value: profile.id, label: profile.name, executorType: profile.executor_type, executorName: profile.executor_name, + disabled: !!disabledReason, + disabledReason: disabledReason ?? undefined, renderLabel: () => ( @@ -232,5 +248,5 @@ export function useExecutorProfileOptions( ), }; }); - }, [allProfiles]); + }, [allProfiles, disabledReasonFor]); } diff --git a/apps/web/components/task-create-dialog-repo-chips.test.tsx b/apps/web/components/task-create-dialog-repo-chips.test.tsx new file mode 100644 index 0000000000..8b9ad79adf --- /dev/null +++ b/apps/web/components/task-create-dialog-repo-chips.test.tsx @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import type { Branch, Repository } from "@/lib/types/http"; +import type { DialogFormState, TaskRepoRow } from "./task-create-dialog-types"; + +// One mocked branches hook now — id-based and path-based rows go through +// the same loader. Tests can override the return value when they need +// branch-specific assertions; the mockBySource handle lets a test prove +// which kind of source the chip passed in. +const lastBranchSource = vi.hoisted((): { value: unknown } => ({ value: null })); +const mockBranches = vi.hoisted((): { value: { branches: Branch[]; isLoading: boolean } } => ({ + value: { branches: [], isLoading: false }, +})); + +vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({ + useBranches: (source: unknown) => { + lastBranchSource.value = source; + return mockBranches.value; + }, +})); + +import { RepoChipsRow } from "./task-create-dialog-repo-chips"; + +afterEach(cleanup); + +const REPO_FRONT_ID = "repo-front"; +const REPO_BACK_ID = "repo-back"; + +function makeRepo(id: string, name: string): Repository { + return { + id, + workspace_id: "ws-1", + name, + source_type: "local", + local_path: `/repos/${name}`, + default_branch: "main", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as Repository; +} + +function row(overrides: Partial = {}): TaskRepoRow { + return { key: `row-${Math.random()}`, branch: "", ...overrides }; +} + +function makeFs(overrides: Partial): DialogFormState { + // Only the fields RepoChipsRow actually reads/sets need to be real. + return { + repositories: [] as TaskRepoRow[], + useGitHubUrl: false, + discoveredRepositories: [], + githubUrl: "", + githubUrlError: null, + githubBranch: "", + githubBranches: [] as Branch[], + githubBranchesLoading: false, + setGitHubBranch: vi.fn(), + addRepository: vi.fn(), + removeRepository: vi.fn(), + updateRepository: vi.fn(), + setRepositories: vi.fn(), + ...overrides, + } as unknown as DialogFormState; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- noop for test-only callback signature +const NOOP = (_key: string, _value: string) => undefined; + +// eslint-disable-next-line max-lines-per-function -- test describe block, splitting hurts readability +describe("RepoChipsRow", () => { + it("renders one chip per row plus an Add button", () => { + render( + , + ); + expect(screen.getAllByTestId("repo-chip")).toHaveLength(1); + expect(screen.getByTestId("add-repository")).toBeTruthy(); + }); + + it("renders one chip per row across multiple repos", () => { + render( + , + ); + expect(screen.getAllByTestId("repo-chip")).toHaveLength(2); + }); + + it("renders the GitHub URL input in URL mode (chips suppressed)", () => { + render( + undefined} + onGitHubUrlChange={() => undefined} + />, + ); + expect(screen.getByTestId("repo-chips-row")).toBeTruthy(); + expect(screen.queryAllByTestId("repo-chip")).toHaveLength(0); + expect(screen.getByTestId("github-url-input")).toBeTruthy(); + }); + + it("hides the chip row when the task is already started", () => { + render( + , + ); + expect(screen.queryByTestId("repo-chips-row")).toBeNull(); + }); + + it("disables Add when no more repositories are available", () => { + render( + , + ); + const btn = screen.getByTestId("add-repository") as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it("calls fs.addRepository when the + button is clicked", () => { + const fs = makeFs({ repositories: [row({ key: "r0", repositoryId: REPO_FRONT_ID })] }); + render( + , + ); + fireEvent.click(screen.getByTestId("add-repository")); + expect(fs.addRepository).toHaveBeenCalledOnce(); + }); + + it("removing a chip calls fs.removeRepository(key)", () => { + const fs = makeFs({ + repositories: [ + row({ key: "r0", repositoryId: REPO_FRONT_ID, branch: "main" }), + row({ key: "r1", repositoryId: REPO_BACK_ID, branch: "develop" }), + ], + }); + render( + , + ); + fireEvent.click(screen.getAllByTestId("remove-repo-chip")[0]); + expect(fs.removeRepository).toHaveBeenCalledWith("r0"); + }); + + // Regression: discovered (on-machine) repos must surface in the picker + // dropdown alongside workspace repos. A previous rewrite passed [] for + // discovered repos and lost them. + it("includes discovered (on-machine) repos in the picker dropdown", () => { + render( + , + ); + fireEvent.click(screen.getByTestId("repo-chip-trigger")); + expect(screen.getByText("frontend")).toBeTruthy(); + expect(screen.getByText("projects/local-project")).toBeTruthy(); + expect(screen.queryByText("frontend-dup")).toBeNull(); + }); + + // Regression: picking a discovered repo passes its path as the value; + // onRowRepositoryChange must receive it (the handler then resolves to a + // workspace id or local path). Previously the chip wrote the path into + // a workspace `repository_id`, causing FK failures on submit. + it("calls onRowRepositoryChange with the discovered path when picked", () => { + const onRowRepositoryChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("repo-chip-trigger")); + fireEvent.click(screen.getByText("projects/local-project")); + expect(onRowRepositoryChange).toHaveBeenCalledWith("r0", "/home/me/projects/local-project"); + }); + + // Regression: discovered (path-keyed) rows used to call the branch loader + // with no path source, so their branch picker stayed empty. The chip must + // build a `kind: "path"` source for discovered rows so the unified hook + // hits the path-based query param instead of trying an id lookup. + it("discovered rows build a path-source for the branch loader", () => { + render( + , + ); + expect(lastBranchSource.value).toEqual({ + kind: "path", + workspaceId: "ws-1", + path: "/home/me/projects/proj", + }); + }); + + it("workspace rows build an id-source for the branch loader", () => { + render( + , + ); + expect(lastBranchSource.value).toEqual({ + kind: "id", + workspaceId: "ws-1", + repositoryId: REPO_FRONT_ID, + }); + }); + + // Regression: remote branches must keep their "origin/" prefix so users + // can distinguish a local "main" from "origin/main". A prior rewrite + // dropped the prefix, producing two indistinguishable rows. + it("remote branches keep their origin/ prefix and don't collide with local names", () => { + mockBranches.value = { + isLoading: false, + branches: [ + { name: "main", type: "local" }, + { name: "main", type: "remote", remote: "origin" }, + ] as unknown as Branch[], + }; + render( + , + ); + fireEvent.click(screen.getByTestId("branch-chip-trigger")); + expect(screen.getByText("main")).toBeTruthy(); + expect(screen.getByText("origin/main")).toBeTruthy(); + // Reset for sibling tests. + mockBranches.value = { branches: [], isLoading: false }; + }); +}); diff --git a/apps/web/components/task-create-dialog-repo-chips.tsx b/apps/web/components/task-create-dialog-repo-chips.tsx new file mode 100644 index 0000000000..ab8ad1a023 --- /dev/null +++ b/apps/web/components/task-create-dialog-repo-chips.tsx @@ -0,0 +1,609 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { IconPlus, IconX, IconCode, IconGitBranch, IconGitFork } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@kandev/ui/command"; +import { useBranches, type BranchSource } from "@/hooks/domains/workspace/use-repository-branches"; +import type { Branch, LocalRepository, Repository } from "@/lib/types/http"; +import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types"; +import { autoSelectBranch } from "@/components/task-create-dialog-helpers"; +import { BranchRefreshButton } from "@/components/branch-refresh-button"; + +/** + * Chip row for the task-create dialog. Renders one chip per row in + * `fs.repositories`, plus a trailing "+" to add another. Each chip is two + * pills (repo, branch) and a remove (×). All chips are equivalent — there + * is no "primary" — and any row can hold either a workspace repo or a + * discovered on-machine path. + * + * In GitHub URL mode the chips are replaced by an inline URL input pill; + * the trailing toggle flips between the two modes. + */ +type RepoChipsRowProps = { + fs: DialogFormState; + repositories: Repository[]; + isTaskStarted: boolean; + /** Required for loading branches on discovered (path-keyed) rows. */ + workspaceId: string | null; + /** + * Per-row repo change handler. Resolves the picked value into either a + * workspace `repositoryId` or a discovered `localPath` and writes that + * into the row. Comes from useDialogHandlers so the resolution logic + * stays in one place. + */ + onRowRepositoryChange: (key: string, value: string) => void; + onRowBranchChange: (key: string, value: string) => void; + /** GitHub URL flow lives alongside the chips so users can switch in place. */ + onToggleGitHubUrl?: () => void; + onGitHubUrlChange?: (value: string) => void; + /** + * Fresh-branch toggle props. When `freshBranchAvailable` is true the toggle + * renders inline at the right edge of the chip row so it sits next to the + * branch pills it affects, instead of taking its own row under the + * agent/executor selectors. + */ + freshBranchAvailable?: boolean; + freshBranchEnabled?: boolean; + onToggleFreshBranch?: (enabled: boolean) => void; + /** + * Lock branch pills when the task runs on the local executor — the user's + * actual checkout dictates the branch, and changing it would mutate their + * working tree. Fresh-branch mode unlocks it (we're explicitly forking a + * new branch from a chosen base). + */ + isLocalExecutor?: boolean; +}; + +export function RepoChipsRow({ + fs, + repositories, + isTaskStarted, + workspaceId, + onRowRepositoryChange, + onRowBranchChange, + onToggleGitHubUrl, + onGitHubUrlChange, + freshBranchAvailable, + freshBranchEnabled, + onToggleFreshBranch, + isLocalExecutor, +}: RepoChipsRowProps) { + const branchLocked = !!isLocalExecutor && !freshBranchEnabled; + // No early returns above hooks. URL mode and started-state checks happen below. + const usedIds = useMemo(() => collectUsedRepoIds(fs.repositories), [fs.repositories]); + if (isTaskStarted) return null; + + const remainingCount = repositories.filter((r) => !usedIds.has(r.id)).length; + // Add stays enabled when no workspace repos are left, since users can also + // pick from discovered on-machine paths. + const hasDiscovered = fs.discoveredRepositories.length > 0; + const canAddMore = remainingCount > 0 || hasDiscovered; + const addHint = canAddMore ? undefined : "All workspace repositories are already added"; + + return ( +
    + {fs.useGitHubUrl ? ( + + ) : ( + + )} + {freshBranchAvailable && onToggleFreshBranch && ( + + )} + {onToggleGitHubUrl && ( + + )} +
    + ); +} + +/** + * Renders the list of repo chips plus the trailing "+ add repository" + * button. Extracted from RepoChipsRow so the parent stays under the + * function-length cap; logic is unchanged. + */ +function ChipsList({ + fs, + repositories, + workspaceId, + branchLocked, + canAddMore, + addHint, + onRowRepositoryChange, + onRowBranchChange, +}: { + fs: DialogFormState; + repositories: Repository[]; + workspaceId: string | null; + branchLocked: boolean; + canAddMore: boolean; + addHint?: string; + onRowRepositoryChange: (key: string, value: string) => void; + onRowBranchChange: (key: string, value: string) => void; +}) { + return ( + <> + {fs.repositories.map((row) => ( + onRowRepositoryChange(row.key, value)} + onBranchChange={(value) => onRowBranchChange(row.key, value)} + onRemove={() => fs.removeRepository(row.key)} + /> + ))} + + + ); +} + +/** Build the set of repo identifiers (workspace id or path) currently in use. */ +function collectUsedRepoIds(rows: TaskRepoRow[], exceptKey?: string): Set { + const ids = new Set(); + for (const r of rows) { + if (r.key === exceptKey) continue; + if (r.repositoryId) ids.add(r.repositoryId); + if (r.localPath) ids.add(r.localPath); + } + return ids; +} + +type RepoChipProps = { + row: TaskRepoRow; + /** Required for path-based branch loading on discovered rows. */ + workspaceId: string | null; + repositories: Repository[]; + discoveredRepositories: LocalRepository[]; + /** Repo IDs/paths to filter out of the dropdown (already in use elsewhere). */ + excludedRepoIds: Set; + /** + * Lock the branch pill regardless of branch availability. Used for the + * local executor where the user's actual checkout dictates the branch + * (and changing it would mutate their working tree). Fresh-branch mode + * unlocks it because we're explicitly creating a new branch from a base. + */ + branchLocked?: boolean; + /** + * Optional display override for the branch value. Used when the chip + * is `branchLocked` so we surface the repo's actual current branch + * instead of whatever the user picked under a different executor. + */ + branchOverride?: string; + onRepositoryChange: (value: string) => void; + onBranchChange: (value: string) => void; + onRemove: () => void; +}; + +function useRepoChipData({ + row, + workspaceId, + repositories, + discoveredRepositories, + excludedRepoIds, + onBranchChange, +}: Pick< + RepoChipProps, + | "row" + | "workspaceId" + | "repositories" + | "discoveredRepositories" + | "excludedRepoIds" + | "onBranchChange" +>) { + const filteredRepos = useMemo( + () => repositories.filter((r) => !excludedRepoIds.has(r.id) || r.id === row.repositoryId), + [repositories, excludedRepoIds, row.repositoryId], + ); + // Discovered (on-machine) repos not yet imported into the workspace. Drop + // ones whose path is already a workspace repo so the dropdown doesn't show + // the same folder twice. + const filteredDiscovered = useMemo(() => { + const workspaceRepoPaths = new Set( + filteredRepos + .map((r) => r.local_path) + .filter(Boolean) + .map((path: string) => normalizeRepoPath(path)), + ); + return discoveredRepositories.filter( + (r) => + !workspaceRepoPaths.has(normalizeRepoPath(r.path)) && + (!excludedRepoIds.has(r.path) || r.path === row.localPath), + ); + }, [filteredRepos, discoveredRepositories, excludedRepoIds, row.localPath]); + + // One hook for both row sources (workspace repo by id OR on-machine path). + // Backend resolves either to an absolute path; the Zustand cache keys + // path-based entries with a synthetic key so workspace + discovered share + // one slice (and the same fetch dedupe). + const branchSource = useMemo(() => { + if (!workspaceId) return null; + if (row.repositoryId) { + return { kind: "id", workspaceId, repositoryId: row.repositoryId }; + } + if (row.localPath) { + return { kind: "path", workspaceId, path: row.localPath }; + } + return null; + }, [workspaceId, row.repositoryId, row.localPath]); + const { + branches, + isLoading: branchesLoading, + refresh: refreshBranches, + } = useBranches(branchSource, !!branchSource); + + // Once branches load for this row, pre-fill the branch pill with the user's + // last-used branch (when present) or main/master/origin/main/origin/master. + // Skipped if the user already picked a branch on this row. + useEffect(() => { + if (!branchSource || branchesLoading || branches.length === 0 || row.branch) return; + autoSelectBranch(branches, onBranchChange); + }, [branchSource, branchesLoading, branches, row.branch, onBranchChange]); + + const repoOptions: PillOption[] = useMemo( + () => [ + ...filteredRepos.map((r) => ({ value: r.id, label: r.name })), + ...filteredDiscovered.map((r) => ({ + value: r.path, + label: shortRepoPath(r.path), + keywords: [r.path], + })), + ], + [filteredRepos, filteredDiscovered], + ); + const branchOptions: PillOption[] = useMemo(() => branches.map(branchToOption), [branches]); + return { repoOptions, branchOptions, branchesLoading, refreshBranches }; +} + +function RepoChip({ + row, + workspaceId, + repositories, + discoveredRepositories, + excludedRepoIds, + branchLocked, + branchOverride, + onRepositoryChange, + onBranchChange, + onRemove, +}: RepoChipProps) { + const { repoOptions, branchOptions, branchesLoading, refreshBranches } = useRepoChipData({ + row, + workspaceId, + repositories, + discoveredRepositories, + excludedRepoIds, + onBranchChange, + }); + const repoLabel = + repositories.find((r) => r.id === row.repositoryId)?.name ?? + discoveredRepositories + .find((r) => r.path === row.localPath) + ?.path?.split("/") + .pop() ?? + ""; + const hasRepo = !!(row.repositoryId || row.localPath); + const branchPlaceholder = computeBranchPlaceholder( + hasRepo, + branchesLoading, + branchOptions.length, + ); + + return ( + + } + value={repoLabel} + placeholder="repository" + options={repoOptions} + onSelect={onRepositoryChange} + searchPlaceholder="Search repositories..." + emptyMessage="No repositories" + testId="repo-chip-trigger" + /> + } + value={branchOverride ?? row.branch} + placeholder={branchPlaceholder} + options={branchOptions} + onSelect={onBranchChange} + disabled={branchLocked || !hasRepo || branchesLoading || branchOptions.length === 0} + searchPlaceholder="Search branches..." + emptyMessage="No branches" + testId="branch-chip-trigger" + onRefresh={refreshBranches} + refreshing={branchesLoading} + /> + + + ); +} + +type PillOption = { value: string; label: string; keywords?: string[] }; + +function computeBranchPlaceholder(hasRepo: boolean, loading: boolean, optionCount: number): string { + if (!hasRepo) return "branch"; + if (loading) return "loading…"; + if (optionCount === 0) return "no branches"; + return "branch"; +} + +function normalizeRepoPath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+$/g, ""); +} + +function shortRepoPath(path: string): string { + // Show the trailing folder name with one parent for context (e.g. "myorg/myrepo"). + const parts = path.replace(/\\/g, "/").split("/").filter(Boolean); + if (parts.length <= 1) return path; + return parts.slice(-2).join("/"); +} + +function branchToOption(b: Branch): PillOption { + // Remote branches keep their "origin/" prefix so they're distinguishable + // from local branches with the same short name (e.g. "main" vs "origin/main"). + // Without the prefix, the dropdown shows two indistinguishable rows. + const display = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name; + return { value: display, label: display }; +} + +/** + * Compact pill trigger that opens a popover with a search list. Auto-widths + * to its content (no `w-full`, no chevron) so multiple pills can sit on one + * line without overlapping or stretching to fill the row. + */ +function Pill({ + icon, + value, + placeholder, + options, + onSelect, + disabled = false, + searchPlaceholder, + emptyMessage, + testId, + onRefresh, + refreshing, +}: { + icon: React.ReactNode; + value: string; + placeholder: string; + options: PillOption[]; + onSelect: (value: string) => void; + disabled?: boolean; + searchPlaceholder: string; + emptyMessage: string; + testId?: string; + /** Optional refresh action rendered next to the search input. */ + onRefresh?: () => void; + /** Show the refresh icon as spinning + disabled while a refresh is in flight. */ + refreshing?: boolean; +}) { + const [open, setOpen] = useState(false); + const hasValue = !!value; + return ( + + + + + + +
    + + {onRefresh && } +
    + + {emptyMessage} + + {options.map((option) => ( + { + onSelect(option.value); + setOpen(false); + }} + > + {option.label} + + ))} + + +
    +
    +
    + ); +} + +function GitHubUrlSection({ + githubUrl, + githubUrlError, + githubBranch, + githubBranches, + githubBranchesLoading, + onGitHubUrlChange, + onGitHubBranchChange, +}: { + githubUrl: string; + githubUrlError: string | null; + githubBranch: string; + githubBranches: Branch[]; + githubBranchesLoading: boolean; + onGitHubUrlChange?: (value: string) => void; + onGitHubBranchChange: (value: string) => void; +}) { + // URL mode is always remote-clone-based, so the branch is freely + // selectable regardless of executor type. Local executor's "branch is + // dictated by the user's checkout" rule does not apply here — there is + // no pre-existing local checkout for an arbitrary GitHub URL. + const branchOptions: PillOption[] = useMemo( + () => githubBranches.map(branchToOption), + [githubBranches], + ); + const branchPlaceholder = computeBranchPlaceholder( + !!githubUrl.trim(), + githubBranchesLoading, + branchOptions.length, + ); + return ( + <> + + } + value={githubBranch} + placeholder={branchPlaceholder} + options={branchOptions} + onSelect={onGitHubBranchChange} + disabled={!githubUrl.trim() || githubBranchesLoading || branchOptions.length === 0} + searchPlaceholder="Search branches..." + emptyMessage="No branches" + testId="branch-chip-trigger" + /> + + ); +} + +function GitHubUrlPill({ + githubUrl, + githubUrlError, + onGitHubUrlChange, +}: { + githubUrl: string; + githubUrlError: string | null; + onGitHubUrlChange?: (value: string) => void; +}) { + return ( +
    + onGitHubUrlChange?.(e.target.value)} + placeholder="github.com/owner/repo" + data-testid="github-url-input" + aria-label="GitHub repository URL" + aria-invalid={!!githubUrlError} + aria-describedby={githubUrlError ? "github-url-error" : undefined} + className={cn( + "h-7 rounded-md px-2.5 text-xs bg-muted/30 border border-border/60", + "outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground", + githubUrlError && "border-destructive text-destructive", + )} + autoFocus + /> + {githubUrlError && ( + + )} +
    + ); +} diff --git a/apps/web/components/task-create-dialog-repositories-state.ts b/apps/web/components/task-create-dialog-repositories-state.ts new file mode 100644 index 0000000000..3368a1258d --- /dev/null +++ b/apps/web/components/task-create-dialog-repositories-state.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import type { TaskRepoRow } from "@/components/task-create-dialog-types"; + +/** + * Manages the unified `repositories` list for task creation. Every chip + * (one or many) is an entry; there is no "primary" or "extras" split. + * + * `nextKey` increments to give each row a stable client-side key without + * relying on array indices (which would shift on removal and break + * uncontrolled inputs). + */ +export function useRepositoriesState() { + const [repositories, setRepositories] = useState([]); + const nextKeyRef = useRef(0); + + const addRepository = useCallback(() => { + nextKeyRef.current += 1; + const key = `row-${nextKeyRef.current}`; + setRepositories((rows) => [...rows, { key, branch: "" }]); + }, []); + + const removeRepository = useCallback((key: string) => { + setRepositories((rows) => rows.filter((r) => r.key !== key)); + }, []); + + const updateRepository = useCallback((key: string, patch: Partial) => { + setRepositories((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); + }, []); + + return { repositories, setRepositories, addRepository, removeRepository, updateRepository }; +} diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 3099092e8f..79934d7012 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -85,6 +85,7 @@ type BranchSelectorProps = { placeholder: string; searchPlaceholder: string; emptyMessage: string; + triggerClassName?: string; onRefresh?: () => void; refreshing?: boolean; fetchedAt?: string; @@ -100,6 +101,7 @@ export const BranchSelector = memo(function BranchSelector({ placeholder, searchPlaceholder, emptyMessage, + triggerClassName, onRefresh, refreshing, fetchedAt, @@ -125,6 +127,7 @@ export const BranchSelector = memo(function BranchSelector({ disabled={disabled} dropdownLabel="Base Branch" className={disabled ? undefined : CURSOR_POINTER_CLASS} + triggerClassName={triggerClassName} testId="branch-selector" filter={scoreBranch} headerAction={headerAction} @@ -260,10 +263,9 @@ export const InlineTaskName = memo(function InlineTaskName({ type="text" value={value} onChange={(e) => onChange(e.target.value)} - placeholder="task-name" + placeholder="Task name" data-testid="task-title-input" - size={Math.max(value.length, 9)} - className="bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-muted focus:bg-muted transition-colors" + className="w-full bg-transparent border-none outline-none focus:ring-0 text-sm font-medium rounded-md px-2 py-1 -mx-2 hover:bg-muted/30 focus:bg-muted/30 transition-colors placeholder:text-muted-foreground/70" /> ); }); diff --git a/apps/web/components/task-create-dialog-setup.tsx b/apps/web/components/task-create-dialog-setup.tsx index 6c91d50b4b..7cc1b9a150 100644 --- a/apps/web/components/task-create-dialog-setup.tsx +++ b/apps/web/components/task-create-dialog-setup.tsx @@ -169,12 +169,12 @@ function useSubmitHandlersWiring({ workflowId, effectiveWorkflowId: computed.effectiveWorkflowId, effectiveDefaultStepId: computed.effectiveDefaultStepId, - repositoryId: fs.repositoryId, - selectedLocalRepo: fs.selectedLocalRepo, + repositories: fs.repositories, + discoveredRepositories: fs.discoveredRepositories, useGitHubUrl: fs.useGitHubUrl, githubUrl: fs.githubUrl, githubPrHeadBranch: fs.githubPrHeadBranch, - branch: fs.branch, + githubBranch: fs.githubBranch, agentProfileId: computed.effectiveAgentProfileId, executorId: fs.executorId, executorProfileId: fs.executorProfileId, @@ -190,8 +190,8 @@ function useSubmitHandlersWiring({ setHasTitle: fs.setHasTitle, setHasDescription: fs.setHasDescription, setTaskName: fs.setTaskName, - setRepositoryId: fs.setRepositoryId, - setBranch: fs.setBranch, + setRepositories: fs.setRepositories, + setGitHubBranch: fs.setGitHubBranch, setAgentProfileId: fs.setAgentProfileId, setExecutorId: fs.setExecutorId, setSelectedWorkflowId: fs.setSelectedWorkflowId, @@ -221,17 +221,18 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { snapshots, repositories, repositoriesLoading, - branches, branchesLoading, - refreshBranches, - branchesFetchedAt, - branchesFetchError, computed, } = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs); + // Multi-repo: the "repo local path" used by the fresh-branch preflight is + // the first chip's path. Empty when no chip has a repo selected (URL mode + // or empty rows). Discovered (path-only) chips short-circuit the lookup. const repositoryLocalPath = (() => { - if (fs.selectedLocalRepo) return fs.selectedLocalRepo.path; - if (fs.repositoryId) { - const repo = repositories.find((r) => r.id === fs.repositoryId); + const first = fs.repositories[0]; + if (!first) return ""; + if (first.localPath) return first.localPath; + if (first.repositoryId) { + const repo = repositories.find((r) => r.id === first.repositoryId); return repo?.local_path ?? ""; } return ""; @@ -242,7 +243,6 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { workflowId, repositories, repositoriesLoading, - branches, agentProfiles, executors, workspaceDefaults: computed.workspaceDefaults, @@ -274,9 +274,6 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { snapshots, repositoriesLoading, branchesLoading, - refreshBranches, - branchesFetchedAt, - branchesFetchError, computed, handlers, submitHandlers, diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index b88c9159d2..37d3e90fc2 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -5,7 +5,6 @@ import type { LocalRepository, Workspace, ExecutorProfile, Branch } from "@/lib/ import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; import { useAppStore } from "@/components/state-provider"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; -import { useRepositoryBranches } from "@/hooks/domains/workspace/use-repository-branches"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useRepositoryOptions, @@ -16,13 +15,27 @@ import { useIsLocalExecutor, } from "@/components/task-create-dialog-options"; import { getTaskCreateDraft, setTaskCreateDraft, removeTaskCreateDraft } from "@/lib/local-storage"; + +/** + * Multi-repo tasks currently only run on the git-worktree executor — + * Docker/Sprites/etc. don't yet know how to provision N sibling repos under + * one task root. Returning a non-null reason marks the option as disabled in + * the executor selector and surfaces this string as a tooltip. The dialog + * only applies this when 2+ repos are selected (see isMultiRepoSelection). + */ +function nonWorktreeDisabledReason(profile: ExecutorProfile): string | null { + if ((profile.executor_type ?? "") === "worktree") return null; + return "Multi-repo tasks only support the git-worktree executor."; +} import type { StepType, TaskCreateDialogInitialValues, DialogFormState, DialogComputedValues, DialogComputedArgs, + TaskRepoRow, } from "@/components/task-create-dialog-types"; +import { useRepositoriesState } from "@/components/task-create-dialog-repositories-state"; import { computePassthroughProfile } from "@/components/task-create-dialog-helpers"; import { computeDialogDefaultStepId, @@ -40,17 +53,14 @@ type FormResetters = { setTaskName: (v: string) => void; setHasTitle: (v: boolean) => void; setHasDescription: (v: boolean) => void; - setRepositoryId: (v: string) => void; - setBranch: (v: string) => void; + setRepositories: (v: TaskRepoRow[]) => void; + setGitHubBranch: (v: string) => void; setAgentProfileId: (v: string) => void; setExecutorId: (v: string) => void; setExecutorProfileId: (v: string) => void; setSelectedWorkflowId: (v: string | null) => void; setFetchedSteps: (v: StepType[] | null) => void; setDiscoveredRepositories: (v: LocalRepository[]) => void; - setDiscoveredRepoPath: (v: string) => void; - setSelectedLocalRepo: (v: LocalRepository | null) => void; - setLocalBranches: (v: Branch[]) => void; setDiscoverReposLoaded: (v: boolean) => void; setUseGitHubUrl: (v: boolean) => void; setGitHubUrl: (v: string) => void; @@ -143,8 +153,21 @@ function resetTaskForm( resetters.setTaskName(name); resetters.setHasTitle(name.trim().length > 0); resetters.setHasDescription(description.trim().length > 0); - resetters.setRepositoryId(initialValues?.repositoryId ?? ""); - resetters.setBranch(initialValues?.branch ?? ""); + // Seed the unified repos list from initialValues. A repo + branch pre-fill + // becomes a single row; nothing seeds an empty list (the auto-select + // effect later picks the user's last-used repo or the first workspace one). + if (initialValues?.repositoryId) { + resetters.setRepositories([ + { + key: "row-0", + repositoryId: initialValues.repositoryId, + branch: initialValues.branch ?? "", + }, + ]); + } else { + resetters.setRepositories([]); + } + resetters.setGitHubBranch(initialValues?.branch ?? ""); resetters.setAgentProfileId(""); resetters.setExecutorId(""); resetters.setExecutorProfileId(""); @@ -156,9 +179,6 @@ function resetTaskForm( function resetDiscoveryState(resetters: FormResetters, iv?: TaskCreateDialogInitialValues) { const ghUrl = iv?.githubUrl ?? ""; resetters.setDiscoveredRepositories([]); - resetters.setDiscoveredRepoPath(""); - resetters.setSelectedLocalRepo(null); - resetters.setLocalBranches([]); resetters.setDiscoverReposLoaded(false); resetters.setUseGitHubUrl(Boolean(ghUrl)); resetters.setGitHubUrl(ghUrl); @@ -277,8 +297,9 @@ function useFormStateValues( const [draftDescription, setDraftDescription] = useState(""); const descriptionInputRef = useRef(null); - const [repositoryId, setRepositoryId] = useState(initialValues?.repositoryId ?? ""); - const [branch, setBranch] = useState(initialValues?.branch ?? ""); + // GitHub URL flow has its own branch field (the per-repo branch lives on + // each row in `repositories`). Seed from initialValues for the URL flow only. + const [githubBranch, setGitHubBranch] = useState(initialValues?.branch ?? ""); const [agentProfileId, setAgentProfileId] = useState(""); const [executorId, setExecutorId] = useState(""); const [executorProfileId, setExecutorProfileId] = useState(""); @@ -296,10 +317,8 @@ function useFormStateValues( draftDescription, setDraftDescription, descriptionInputRef, - repositoryId, - setRepositoryId, - branch, - setBranch, + githubBranch, + setGitHubBranch, agentProfileId, setAgentProfileId, executorId, @@ -322,26 +341,17 @@ function useFormStateValues( }; } -/** Repository discovery state */ +/** Repository discovery state — just the discovered list. The previous + * per-form `selectedLocalRepo` / `discoveredRepoPath` / `localBranches` + * primary-only fields are gone; discovered repos now live as ordinary rows + * in `fs.repositories` with `localPath` set. */ function useDiscoveryState() { const [discoveredRepositories, setDiscoveredRepositories] = useState([]); - const [discoveredRepoPath, setDiscoveredRepoPath] = useState(""); - const [selectedLocalRepo, setSelectedLocalRepo] = useState(null); - const [localBranches, setLocalBranches] = useState([]); - const [localBranchesLoading, setLocalBranchesLoading] = useState(false); const [discoverReposLoading, setDiscoverReposLoading] = useState(false); const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false); return { discoveredRepositories, setDiscoveredRepositories, - discoveredRepoPath, - setDiscoveredRepoPath, - selectedLocalRepo, - setSelectedLocalRepo, - localBranches, - setLocalBranches, - localBranchesLoading, - setLocalBranchesLoading, discoverReposLoading, setDiscoverReposLoading, discoverReposLoaded, @@ -359,6 +369,7 @@ export function useDialogFormState( const discovery = useDiscoveryState(); const ghUrl = useGitHubUrlState(); const wfAgent = useWorkflowAgentProfileState(); + const repos = useRepositoriesState(); const freshBranch = useFreshBranchState(); useFormResetEffects({ @@ -374,17 +385,14 @@ export function useDialogFormState( setTaskName: form.setTaskName, setHasTitle: form.setHasTitle, setHasDescription: form.setHasDescription, - setRepositoryId: form.setRepositoryId, - setBranch: form.setBranch, + setRepositories: repos.setRepositories, + setGitHubBranch: form.setGitHubBranch, setAgentProfileId: form.setAgentProfileId, setExecutorId: form.setExecutorId, setExecutorProfileId: form.setExecutorProfileId, setSelectedWorkflowId: form.setSelectedWorkflowId, setFetchedSteps: form.setFetchedSteps, setDiscoveredRepositories: discovery.setDiscoveredRepositories, - setDiscoveredRepoPath: discovery.setDiscoveredRepoPath, - setSelectedLocalRepo: discovery.setSelectedLocalRepo, - setLocalBranches: discovery.setLocalBranches, setDiscoverReposLoaded: discovery.setDiscoverReposLoaded, setUseGitHubUrl: ghUrl.setUseGitHubUrl, setGitHubUrl: ghUrl.setGitHubUrl, @@ -404,7 +412,7 @@ export function useDialogFormState( form.descriptionInputRef, ); - return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...freshBranch, clearDraft }; + return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...repos, ...freshBranch, clearDraft }; } export type { DialogFormState } from "@/components/task-create-dialog-types"; @@ -415,6 +423,7 @@ export { } from "@/components/task-create-dialog-helpers"; export { useTaskCreateDialogEffects } from "@/components/task-create-dialog-effects"; +// useDialogHandlers lives in ./task-create-dialog-handlers.ts export { useDialogHandlers } from "@/components/task-create-dialog-handlers"; export function useDialogComputed({ @@ -423,7 +432,6 @@ export function useDialogComputed({ workspaceId, workflowId, defaultStepId, - branches, settingsData, agentProfiles, workspaces, @@ -464,15 +472,17 @@ export function useDialogComputed({ const workspaceDefaults = workspaceId ? workspaces.find((ws: Workspace) => ws.id === workspaceId) : null; + // The form has a repo selection when either: (a) any chip in the unified + // list has a repo set, or (b) URL mode has a non-empty URL. The chip row + // takes care of branch state per row; there is no global branch. const hasRepositorySelection = Boolean( - fs.repositoryId || fs.selectedLocalRepo || (fs.useGitHubUrl && fs.githubUrl.trim()), + fs.repositories.some((r) => r.repositoryId || r.localPath) || + (fs.useGitHubUrl && fs.githubUrl.trim()), ); - const effectiveBranches = (() => { - if (fs.useGitHubUrl) return fs.githubBranches; - if (fs.repositoryId) return branches; - return fs.localBranches; - })(); - const branchOptions = useBranchOptions(effectiveBranches); + // Branch options are only used by the URL-mode flow now (the chip's branch + // pill loads branches per-repo). Keep the computed value but always feed it + // the URL branches when in URL mode. + const branchOptions = useBranchOptions(fs.useGitHubUrl ? fs.githubBranches : []); const agentProfileOptions = useAgentProfileOptions(agentProfiles); const allExecutorProfiles = useMemo(() => { return executors.flatMap((executor) => @@ -483,7 +493,15 @@ export function useDialogComputed({ })), ); }, [executors]); - const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles); + // Multi-repo tasks only run on the git-worktree executor today — Docker / + // Sprites / standalone don't yet know how to provision N sibling repos. Gate + // non-worktree options only when 2+ repos are selected; single-repo tasks + // keep the full executor catalogue. + const isMultiRepoSelection = + fs.repositories.filter((r) => r.repositoryId || r.localPath).length > 1; + const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, { + disabledReasonFor: isMultiRepoSelection ? nonWorktreeDisabledReason : undefined, + }); const executorHint = useExecutorHint(executors, fs.executorId); const isLocalExecutor = useIsLocalExecutor(executors, fs.executorId); const { headerRepositoryOptions } = useRepositoryOptions(repositories, fs.discoveredRepositories); @@ -542,20 +560,16 @@ export function useTaskCreateDialogData( useSettingsData(open); const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, open); - const { - branches, - isLoading: branchesLoading, - refresh: refreshBranches, - fetchedAt: branchesFetchedAt, - fetchError: branchesFetchError, - } = useRepositoryBranches(fs.repositoryId || null, Boolean(open && fs.repositoryId)); + // Per-repo branch loading lives in each chip now (RepoChipsRow). No + // global branch query is needed here — the chip uses useRepositoryBranches + // for its own row, and the store dedupes by repositoryId. + const branchesLoading = false; const computed = useDialogComputed({ fs, open, workspaceId, workflowId, defaultStepId, - branches, settingsData, agentProfiles, workspaces, @@ -572,11 +586,7 @@ export function useTaskCreateDialogData( snapshots, repositories, repositoriesLoading, - branches, branchesLoading, - refreshBranches, - branchesFetchedAt, - branchesFetchError, computed, }; } diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 1a0667a48d..2717feba53 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -31,12 +31,12 @@ export function useTaskSubmitHandlers({ workflowId, effectiveWorkflowId, effectiveDefaultStepId, - repositoryId, - selectedLocalRepo, + repositories, + discoveredRepositories, useGitHubUrl, githubUrl, githubPrHeadBranch, - branch, + githubBranch, agentProfileId, executorId, executorProfileId, @@ -52,8 +52,8 @@ export function useTaskSubmitHandlers({ setHasTitle, setHasDescription, setTaskName, - setRepositoryId, - setBranch, + setRepositories, + setGitHubBranch, setAgentProfileId, setExecutorId, setSelectedWorkflowId, @@ -88,20 +88,19 @@ export function useTaskSubmitHandlers({ trimmedTitle, workspaceId, effectiveWorkflowId, - repositoryId, - selectedLocalRepo, + repositories, githubUrl, agentProfileId, }), - [workspaceId, effectiveWorkflowId, repositoryId, selectedLocalRepo, githubUrl, agentProfileId], + [workspaceId, effectiveWorkflowId, repositories, githubUrl, agentProfileId], ); const resetForm = useCallback(() => { setHasTitle(false); setHasDescription(false); setTaskName(""); - setRepositoryId(""); - setBranch(""); + setRepositories([]); + setGitHubBranch(""); setAgentProfileId(""); setExecutorId(""); setSelectedWorkflowId(workflowId); @@ -112,8 +111,8 @@ export function useTaskSubmitHandlers({ setHasTitle, setHasDescription, setTaskName, - setRepositoryId, - setBranch, + setRepositories, + setGitHubBranch, setAgentProfileId, setExecutorId, setSelectedWorkflowId, @@ -125,21 +124,21 @@ export function useTaskSubmitHandlers({ buildRepositoriesPayload({ useGitHubUrl, githubUrl, - branch, + githubBranch, githubPrHeadBranch, - repositoryId, - selectedLocalRepo, + repositories, + discoveredRepositories, freshBranch: buildFreshBranchPayload(consentedDirtyFiles), }), - // buildFreshBranchPayload is intentionally a closure over current scope; lint exception kept narrow. + // buildFreshBranchPayload is a closure over current scope; lint exception kept narrow. // eslint-disable-next-line react-hooks/exhaustive-deps [ useGitHubUrl, - repositoryId, - branch, githubUrl, + githubBranch, githubPrHeadBranch, - selectedLocalRepo, + repositories, + discoveredRepositories, isFreshBranchActive, ], ); diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index cc59b0a58c..88ddc13e8b 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -16,6 +16,26 @@ import type { } from "@/components/task-create-dialog-options"; import type { useToast } from "@/components/toast-provider"; +/** + * One repository row in the task-create form. The form tracks every repo + * (whether one or many) as an entry in `repositories[]`. There is no + * "primary" — the backend treats them all equally and uses array order + * for position. Removing the last row clears the form's repo selection. + * + * Exactly one of `repositoryId` (workspace repo) or `localPath` (on-machine + * git folder discovered via the workspace's repo discovery) is set per row. + * `key` is a stable client-side id used as React key and for + * add/update/remove ops; it's not sent to the backend. + */ +export type TaskRepoRow = { + key: string; + /** Workspace repo id, when the user picked from the workspace's repos. */ + repositoryId?: string; + /** On-machine repo path, when the user picked from discovered repos. */ + localPath?: string; + branch: string; +}; + export type StepType = { id: string; title: string; @@ -73,7 +93,6 @@ export type DialogComputedArgs = { workspaceId: string | null; workflowId: string | null; defaultStepId: string | null; - branches: Branch[]; settingsData: { agentsLoaded: boolean; executorsLoaded: boolean }; agentProfiles: AgentProfileOption[]; workspaces: Workspace[]; @@ -89,7 +108,6 @@ export type TaskCreateEffectsArgs = { workflowId: string | null; repositories: Repository[]; repositoriesLoading: boolean; - branches: Branch[]; agentProfiles: AgentProfileOption[]; executors: Executor[]; workspaceDefaults: Workspace | null | undefined; @@ -119,10 +137,19 @@ export type DialogFormState = { /** Computed defaults for current open cycle (includes draft restoration) */ currentDefaults: { name: string; description: string }; descriptionInputRef: import("react").RefObject; - repositoryId: string; - setRepositoryId: (v: string) => void; - branch: string; - setBranch: (v: string) => void; + /** + * Unified list of repos on this task. Each row carries either a workspace + * `repositoryId` or a discovered `localPath`, plus its base `branch`. The + * order is the position the backend sees. There is no "primary" concept. + */ + repositories: TaskRepoRow[]; + setRepositories: (v: TaskRepoRow[]) => void; + addRepository: () => void; + removeRepository: (key: string) => void; + updateRepository: (key: string, patch: Partial) => void; + /** GitHub URL mode: a separate flow that replaces the chip row with a URL input. */ + githubBranch: string; + setGitHubBranch: (v: string) => void; agentProfileId: string; setAgentProfileId: (v: string) => void; executorId: string; @@ -131,14 +158,6 @@ export type DialogFormState = { setExecutorProfileId: (v: string) => void; discoveredRepositories: LocalRepository[]; setDiscoveredRepositories: (v: LocalRepository[]) => void; - discoveredRepoPath: string; - setDiscoveredRepoPath: (v: string) => void; - selectedLocalRepo: LocalRepository | null; - setSelectedLocalRepo: (v: LocalRepository | null) => void; - localBranches: Branch[]; - setLocalBranches: (v: Branch[]) => void; - localBranchesLoading: boolean; - setLocalBranchesLoading: (v: boolean) => void; discoverReposLoading: boolean; setDiscoverReposLoading: (v: boolean) => void; discoverReposLoaded: boolean; @@ -185,12 +204,15 @@ export type SubmitHandlersDeps = { workflowId: string | null; effectiveWorkflowId: string | null; effectiveDefaultStepId: string | null; - repositoryId: string; - selectedLocalRepo: LocalRepository | null; + /** Unified repo list from the form. Empty when in GitHub URL mode. */ + repositories: TaskRepoRow[]; + /** All on-machine discovered repos — used to look up `default_branch` for `localPath` rows. */ + discoveredRepositories: LocalRepository[]; useGitHubUrl: boolean; githubUrl: string; githubPrHeadBranch: string | null; - branch: string; + /** Branch for the GitHub URL flow. Per-row branches live on `repositories[i].branch`. */ + githubBranch: string; agentProfileId: string; executorId: string; executorProfileId: string; @@ -217,8 +239,8 @@ export type SubmitHandlersDeps = { setHasTitle: (v: boolean) => void; setHasDescription: (v: boolean) => void; setTaskName: (v: string) => void; - setRepositoryId: (v: string) => void; - setBranch: (v: string) => void; + setRepositories: (v: TaskRepoRow[]) => void; + setGitHubBranch: (v: string) => void; setAgentProfileId: (v: string) => void; setExecutorId: (v: string) => void; setSelectedWorkflowId: (v: string | null) => void; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index f527ac25b6..459ca42135 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -1,35 +1,100 @@ "use client"; +import { FormEvent, useCallback } from "react"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog"; +import type { Task, Repository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; -import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; +import { SHORTCUTS } from "@/lib/keyboard/constants"; +import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; +import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; +import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer"; import { DiscardLocalChangesDialog } from "@/components/discard-local-changes-dialog"; import { DialogHeaderContent } from "@/components/task-create-dialog-header"; import { - CreateEditSelectors, SessionSelectors, WorkflowSection, DialogPromptSection, } from "@/components/task-create-dialog-form-body"; -import { useBranchOptions, useAgentProfileOptions } from "@/components/task-create-dialog-options"; +import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; import { - BranchSelector, AgentSelector, ExecutorProfileSelector, + InlineTaskName, } from "@/components/task-create-dialog-selectors"; import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit"; -import { type DialogFormState } from "@/components/task-create-dialog-state"; +import { CreateModeSelectors } from "@/components/task-create-dialog-create-mode-selectors"; +import { RepoChipsRow } from "@/components/task-create-dialog-repo-chips"; +import { useToast } from "@/components/toast-provider"; import { - useTaskCreateDialogSetup, - type TaskCreateDialogProps, -} from "@/components/task-create-dialog-setup"; + useDialogFormState, + useTaskCreateDialogEffects, + useDialogHandlers, + useSessionRepoName, + useTaskCreateDialogData, + computeIsTaskStarted, + type DialogFormState, + type TaskCreateDialogInitialValues, +} from "@/components/task-create-dialog-state"; + +interface TaskCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode?: "create" | "edit" | "session"; + workspaceId: string | null; + workflowId: string | null; + defaultStepId: string | null; + steps: Array<{ + id: string; + title: string; + events?: { + on_enter?: Array<{ type: string; config?: Record }>; + on_turn_complete?: Array<{ type: string; config?: Record }>; + }; + }>; + editingTask?: { + id: string; + title: string; + description?: string; + workflowStepId: string; + state?: Task["state"]; + repositoryId?: string; + } | null; + onSuccess?: ( + task: Task, + mode: "create" | "edit", + meta?: { taskSessionId?: string | null }, + ) => void; + onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void; + initialValues?: TaskCreateDialogInitialValues; + taskId?: string | null; + parentTaskId?: string; + /** + * Pin specific form fields to their initial values (used by feature wrappers + * like Improve Kandev that fix the repo + branch + workflow). The current + * implementation just passes the locks through; the chip row's first repo + * is overwritten on each open. The flags are kept for forward compat with + * locking the editor UI itself in a future pass. + */ + lockedFields?: { repository?: boolean; branch?: boolean; workflow?: boolean }; + /** Optional submit hook used by Improve Kandev to wrap the description. */ + transformDescriptionBeforeSubmit?: (description: string) => Promise | string; + /** Optional override for the description placeholder. */ + descriptionPlaceholder?: string; + /** Optional render slot above the description editor. */ + aboveDescriptionSlot?: React.ReactNode; + /** Optional render slot inside the dialog (between body and footer). */ + extraFormSlot?: React.ReactNode; + /** Optional render slot at the bottom of the dialog footer area. */ + bottomSlot?: React.ReactNode; +} type DialogFormBodyProps = { isSessionMode: boolean; isCreateMode: boolean; + isEditMode: boolean; isTaskStarted: boolean; isPassthroughProfile: boolean; initialDescription: string; @@ -37,11 +102,6 @@ type DialogFormBodyProps = { workspaceId: string | null; onJiraImport?: (ticket: JiraTicket) => void; onLinearImport?: (issue: LinearIssue) => void; - branchOptions: ReturnType; - branchesLoading: boolean; - onRefreshBranches: () => void; - branchesFetchedAt?: string; - branchesFetchError?: string; agentProfileOptions: ReturnType; executorProfileOptions: Array<{ value: string; @@ -57,365 +117,473 @@ type DialogFormBodyProps = { effectiveWorkflowId: string | null; fs: DialogFormState; handleKeyDown: ReturnType; - onBranchChange: (v: string) => void; + onTaskNameChange: (v: string) => void; + onRowRepositoryChange: (key: string, value: string) => void; + onRowBranchChange: (key: string, value: string) => void; onAgentProfileChange: (v: string) => void; onExecutorProfileChange: (v: string) => void; onWorkflowChange: (v: string) => void; - hasRepositorySelection: boolean; - isLocalExecutor: boolean; + onToggleGitHubUrl?: () => void; + onGitHubUrlChange: (v: string) => void; + onToggleFreshBranch: (enabled: boolean) => void; enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; workflowAgentLocked: boolean; - onToggleFreshBranch: (enabled: boolean) => void; - extraFormSlot?: React.ReactNode; - lockedFields?: TaskCreateDialogProps["lockedFields"]; - descriptionPlaceholder?: string; + /** Workspace repositories — driven into the chip row for repo + branch picks. */ + repositories: Repository[]; + /** Computed in the parent: single-row + local executor + not URL mode. */ + freshBranchAvailable: boolean; + /** + * True when the selected executor profile runs locally on the host. Used + * to lock the per-row branch pill (the user's checkout dictates the + * branch for local execution; fresh-branch mode unlocks it). + */ + isLocalExecutor: boolean; + /** Optional render slot above the description editor. */ aboveDescriptionSlot?: React.ReactNode; + /** Optional render slot inside the dialog body (rendered above the chip row). */ + extraFormSlot?: React.ReactNode; + /** Optional render slot at the bottom of the dialog body (above the footer). */ bottomSlot?: React.ReactNode; + /** Optional override for the description placeholder. */ + descriptionPlaceholder?: string; }; -// eslint-disable-next-line max-lines-per-function -- thin pass-through; each section already factored into its own component -function DialogFormBody(p: DialogFormBodyProps) { +function CreateModeBody(props: DialogFormBodyProps) { const { - isSessionMode, isCreateMode, + isEditMode, isTaskStarted, - branchOptions, - branchesLoading, - onRefreshBranches, - branchesFetchedAt, - branchesFetchError, + workspaceId, + onJiraImport, + onLinearImport, agentProfileOptions, executorProfileOptions, agentProfiles, agentProfilesLoading, executorsLoading, isCreatingSession, - workflows, - snapshots, - effectiveWorkflowId, fs, - onBranchChange, + onTaskNameChange, + onRowRepositoryChange, + onRowBranchChange, onAgentProfileChange, onExecutorProfileChange, - onWorkflowChange, - hasRepositorySelection, - isLocalExecutor, - workflowAgentLocked, + onToggleGitHubUrl, + onGitHubUrlChange, onToggleFreshBranch, - lockedFields, - } = p; + workflowAgentLocked, + repositories, + freshBranchAvailable, + isLocalExecutor, + } = props; + const showTaskName = (isCreateMode || isEditMode) && !isTaskStarted; + return ( + <> + + {showTaskName && ( + + )} + + + {props.bottomSlot} + + ); +} + +function SessionModeBody(props: DialogFormBodyProps) { + return ( + <> + + + + ); +} + +function DialogFormBody(props: DialogFormBodyProps) { + const { isSessionMode, isCreateMode, isTaskStarted, workflows, snapshots } = props; return (
    - : } + [0]["workflows"]} + snapshots={snapshots as Parameters[0]["snapshots"]} + effectiveWorkflowId={props.effectiveWorkflowId} + onWorkflowChange={props.onWorkflowChange} + agentProfiles={props.agentProfiles} /> - - {!isSessionMode && - renderCreateEditSelectors({ - isTaskStarted, - hasRepositorySelection, - branchOptions, - branchesLoading, - onRefreshBranches, - branchesFetchedAt, - branchesFetchError, - agentProfiles, - agentProfilesLoading, - agentProfileOptions, - isCreatingSession, - executorProfileOptions, - executorsLoading, - isLocalExecutor, - workflowAgentLocked, - fs, - onBranchChange, - onAgentProfileChange, - onExecutorProfileChange, - onToggleFreshBranch, - branchLocked: !!lockedFields?.branch, - })} - {!lockedFields?.workflow && ( - [0]["workflows"]} - snapshots={snapshots as Parameters[0]["snapshots"]} - effectiveWorkflowId={effectiveWorkflowId} - onWorkflowChange={onWorkflowChange} - agentProfiles={agentProfiles} - /> - )} - {isSessionMode && ( - - )} - {p.bottomSlot}
    ); } -type CreateEditSelectorsRenderArgs = Pick< - DialogFormBodyProps, - | "isTaskStarted" - | "hasRepositorySelection" - | "branchOptions" - | "branchesLoading" - | "onRefreshBranches" - | "branchesFetchedAt" - | "branchesFetchError" - | "agentProfiles" - | "agentProfilesLoading" - | "agentProfileOptions" - | "isCreatingSession" - | "executorProfileOptions" - | "executorsLoading" - | "isLocalExecutor" - | "workflowAgentLocked" - | "fs" - | "onBranchChange" - | "onAgentProfileChange" - | "onExecutorProfileChange" - | "onToggleFreshBranch" -> & { branchLocked?: boolean }; +function useEnhanceForDialog(fs: DialogFormState) { + const isConfigured = useIsUtilityConfigured(); + const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ + sessionId: null, + taskTitle: fs.taskName, + }); + const onEnhance = useCallback(() => { + const current = fs.descriptionInputRef.current?.getValue()?.trim(); + if (!current) return; + enhancePrompt(current, (enhanced) => { + fs.descriptionInputRef.current?.setValue(enhanced); + fs.setHasDescription(true); + }); + }, [enhancePrompt, fs]); + return { onEnhance, isLoading: isEnhancingPrompt, isConfigured }; +} -function renderCreateEditSelectors(args: CreateEditSelectorsRenderArgs) { - const { fs } = args; - return ( - +function useJiraImportHandler(fs: DialogFormState) { + return useCallback( + (ticket: JiraTicket) => { + const title = `[${ticket.key}] ${ticket.summary}`; + fs.setTaskName(title); + fs.setHasTitle(true); + const description = ticket.description?.trim() + ? `${ticket.description}\n\n---\nJira: ${ticket.url}` + : `Jira: ${ticket.url}`; + fs.descriptionInputRef.current?.setValue(description); + fs.setHasDescription(true); + }, + [fs], ); } -type DialogFormProps = { - setup: ReturnType; - workspaceId: string | null; - extraFormSlot?: React.ReactNode; - lockedFields?: TaskCreateDialogProps["lockedFields"]; - descriptionPlaceholder?: string; - aboveDescriptionSlot?: React.ReactNode; - bottomSlot?: React.ReactNode; +function useLinearImportHandler(fs: DialogFormState) { + return useCallback( + (issue: LinearIssue) => { + const title = `[${issue.identifier}] ${issue.title}`; + fs.setTaskName(title); + fs.setHasTitle(true); + const description = issue.description?.trim() + ? `${issue.description}\n\n---\nLinear: ${issue.url}` + : `Linear: ${issue.url}`; + fs.descriptionInputRef.current?.setValue(description); + fs.setHasDescription(true); + }, + [fs], + ); +} + +type SubmitWiringArgs = { + props: TaskCreateDialogProps; + fs: ReturnType; + computed: ReturnType["computed"]; + repositoryLocalPath: string; + isSessionMode: boolean; + isEditMode: boolean; }; -function DialogForm({ - setup, - workspaceId, - extraFormSlot, - lockedFields, - descriptionPlaceholder, - aboveDescriptionSlot, - bottomSlot, -}: DialogFormProps) { - const { fs, isSessionMode, isCreateMode, isTaskStarted, workflows, agentProfiles } = setup; - const { snapshots, branchesLoading, computed, handlers, handleKeyDown } = setup; - const { handleJiraImport, handleLinearImport, refreshBranches, branchesFetchedAt } = setup; - const { branchesFetchError } = setup; - const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; - const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; - return ( -
    - - - - - - ); +function useSubmitHandlersWiring({ + props, + fs, + computed, + repositoryLocalPath, + isSessionMode, + isEditMode, +}: SubmitWiringArgs) { + const { workspaceId, workflowId, editingTask, onSuccess, onCreateSession, onOpenChange } = props; + const { parentTaskId } = props; + const taskId = props.taskId ?? null; + return useTaskSubmitHandlers({ + isSessionMode, + isEditMode, + isPassthroughProfile: computed.isPassthroughProfile, + taskName: fs.taskName, + workspaceId, + workflowId, + effectiveWorkflowId: computed.effectiveWorkflowId, + effectiveDefaultStepId: computed.effectiveDefaultStepId, + repositories: fs.repositories, + discoveredRepositories: fs.discoveredRepositories, + useGitHubUrl: fs.useGitHubUrl, + githubUrl: fs.githubUrl, + githubPrHeadBranch: fs.githubPrHeadBranch, + githubBranch: fs.githubBranch, + agentProfileId: computed.effectiveAgentProfileId, + executorId: fs.executorId, + executorProfileId: fs.executorProfileId, + editingTask, + onSuccess, + onCreateSession, + onOpenChange, + taskId, + parentTaskId, + descriptionInputRef: fs.descriptionInputRef, + setIsCreatingSession: fs.setIsCreatingSession, + setIsCreatingTask: fs.setIsCreatingTask, + setHasTitle: fs.setHasTitle, + setHasDescription: fs.setHasDescription, + setTaskName: fs.setTaskName, + setRepositories: fs.setRepositories, + setGitHubBranch: fs.setGitHubBranch, + setAgentProfileId: fs.setAgentProfileId, + setExecutorId: fs.setExecutorId, + setSelectedWorkflowId: fs.setSelectedWorkflowId, + setFetchedSteps: fs.setFetchedSteps, + clearDraft: fs.clearDraft, + freshBranchEnabled: fs.freshBranchEnabled, + isLocalExecutor: computed.isLocalExecutor, + repositoryLocalPath, + }); +} + +/** + * Resolves the on-disk path for the (single) selected row, used by the + * fresh-branch consent flow. Multi-row tasks hide fresh-branch in the UI, + * so we only need to resolve a path when there is exactly one row. + */ +function resolveSingleRowLocalPath(fs: DialogFormState, repositories: Repository[]): string { + if (fs.repositories.length !== 1) return ""; + const row = fs.repositories[0]; + if (row.localPath) return row.localPath; + if (row.repositoryId) { + return repositories.find((r) => r.id === row.repositoryId)?.local_path ?? ""; + } + return ""; +} + +function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { + const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props; + const { editingTask, initialValues } = props; + const isSessionMode = mode === "session"; + const isEditMode = mode === "edit"; + const isCreateMode = mode === "create"; + const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask); + const fs = useDialogFormState(open, workspaceId, workflowId, initialValues); + const { toast } = useToast(); + const sessionRepoName = useSessionRepoName(isSessionMode); + const { + workflows, + agentProfiles, + executors, + snapshots, + repositories, + repositoriesLoading, + computed, + } = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs); + const repositoryLocalPath = resolveSingleRowLocalPath(fs, repositories); + useTaskCreateDialogEffects(fs, { + open, + workspaceId, + workflowId, + repositories, + repositoriesLoading, + agentProfiles, + executors, + workspaceDefaults: computed.workspaceDefaults, + toast, + workflows, + }); + const handlers = useDialogHandlers(fs, repositories); + const submitHandlers = useSubmitHandlersWiring({ + props, + fs, + computed, + repositoryLocalPath, + isSessionMode, + isEditMode, + }); + const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => { + submitHandlers.handleSubmit(event as unknown as FormEvent); + }); + // Fresh-branch is single-row + local executor + not URL mode. The chip row + // can hold any number of repos; we hide the toggle whenever the question + // ("which repo do we discard local changes in?") becomes ambiguous. + const freshBranchAvailable = + !fs.useGitHubUrl && computed.isLocalExecutor && fs.repositories.length === 1; + return { + fs, + isSessionMode, + isEditMode, + isCreateMode, + isTaskStarted, + sessionRepoName, + workflows, + agentProfiles, + snapshots, + repositories, + repositoriesLoading, + computed, + handlers, + submitHandlers, + handleKeyDown, + freshBranchAvailable, + enhance: useEnhanceForDialog(fs), + handleJiraImport: useJiraImportHandler(fs), + handleLinearImport: useLinearImportHandler(fs), + }; } export function TaskCreateDialog(props: TaskCreateDialogProps) { + const { open, onOpenChange, initialValues, workspaceId } = props; const setup = useTaskCreateDialogSetup(props); - const { fs, computed, handlers } = setup; + const { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted } = setup; + const { sessionRepoName, workflows, agentProfiles, snapshots, repositories } = setup; + const { computed, handlers, handleKeyDown, freshBranchAvailable } = setup; + const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; + const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; + const { handleJiraImport, handleLinearImport } = setup; return ( - + - - +
    + + + 0 && fs.repositories.every((r) => !!r.branch) + } + agentProfileId={computed.effectiveAgentProfileId} + workspaceId={workspaceId} + effectiveWorkflowId={computed.effectiveWorkflowId ?? null} + executorHint={computed.executorHint} + onCancel={handleCancel} + onUpdateWithoutAgent={handleUpdateWithoutAgent} + onCreateWithoutAgent={handleCreateWithoutAgent} + onCreateWithPlanMode={handleCreateWithPlanMode} + /> + +
    ); } -type RenderHeaderProps = { - initialTitle: string | undefined; - workspaceId: string | null | undefined; - isCreateMode: boolean; - isEditMode: boolean; - isTaskStarted: boolean; - sessionRepoName: string | null | undefined; - fs: ReturnType["fs"]; - repositoriesLoading: boolean; - computed: ReturnType["computed"]; - handlers: ReturnType["handlers"]; - repositoryLocked?: boolean; -}; - -function RenderHeader(props: RenderHeaderProps) { - const { fs, computed, handlers } = props; - return ( - - ); -} - function PendingDiscardModal({ pending, }: { diff --git a/apps/web/components/task/changes-panel-file-row.tsx b/apps/web/components/task/changes-panel-file-row.tsx index 8a1d79c999..7a6d6a457b 100644 --- a/apps/web/components/task/changes-panel-file-row.tsx +++ b/apps/web/components/task/changes-panel-file-row.tsx @@ -32,6 +32,8 @@ type ChangedFile = { plus: number | undefined; minus: number | undefined; oldPath: string | undefined; + /** Multi-repo: name of the repo this file lives in. Empty for single-repo. */ + repositoryName?: string; }; type FileRowProps = { @@ -40,9 +42,12 @@ type FileRowProps = { isSelected?: boolean; onSelect?: (path: string, e: React.MouseEvent) => boolean; onOpenDiff: (path: string) => void; - onStage: (path: string) => void; - onUnstage: (path: string) => void; - onDiscard: (path: string) => void; + // Multi-repo: handlers receive the file's repository_name so the per-file + // staging op routes to the right git repo. Same-named files (e.g. README.md + // in two repos) cannot be disambiguated by path alone. + onStage: (path: string, repo?: string) => void; + onUnstage: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string) => void; onEditFile: (path: string) => void; }; @@ -83,6 +88,7 @@ export function FileRow({ isPending={isPending} staged={file.staged} path={file.path} + repo={file.repositoryName} onStage={onStage} onUnstage={onUnstage} /> @@ -98,7 +104,12 @@ export function FileRow({
    - +
    @@ -110,14 +121,16 @@ function StageButton({ isPending, staged, path, + repo, onStage, onUnstage, }: { isPending: boolean; staged: boolean; path: string; - onStage: (path: string) => void; - onUnstage: (path: string) => void; + repo?: string; + onStage: (path: string, repo?: string) => void; + onUnstage: (path: string, repo?: string) => void; }) { if (isPending) { return ( @@ -134,7 +147,7 @@ function StageButton({ className="group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer" onClick={(e) => { e.stopPropagation(); - onUnstage(path); + onUnstage(path, repo); }} > @@ -149,7 +162,7 @@ function StageButton({ className="flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer" onClick={(e) => { e.stopPropagation(); - onStage(path); + onStage(path, repo); }} > @@ -159,11 +172,13 @@ function StageButton({ function FileRowActions({ path, + repo, onDiscard, onEditFile, }: { path: string; - onDiscard: (path: string) => void; + repo?: string; + onDiscard: (path: string, repo?: string) => void; onEditFile: (path: string) => void; }) { return ( @@ -175,7 +190,7 @@ function FileRowActions({ className="text-muted-foreground hover:text-foreground cursor-pointer" onClick={(e) => { e.stopPropagation(); - onDiscard(path); + onDiscard(path, repo); }} > diff --git a/apps/web/components/task/changes-panel-header.tsx b/apps/web/components/task/changes-panel-header.tsx index 60c07bbcf9..d90f482dc0 100644 --- a/apps/web/components/task/changes-panel-header.tsx +++ b/apps/web/components/task/changes-panel-header.tsx @@ -17,16 +17,76 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuLabel, + DropdownMenuSeparator, } from "@kandev/ui/dropdown-menu"; import { PanelHeaderBarSplit } from "./panel-primitives"; +type PerRepoStatus = { + repository_name: string; + branch: string | null; + ahead: number; + behind: number; + hasStaged: boolean; + hasUnstaged: boolean; +}; + +type BranchRow = { repoLabel: string | null; branch: string; baseBranch: string }; + +/** + * Builds per-repo rows for the branch hover card. Returns [] for single-repo + * workspaces (callers fall back to the single-row layout); otherwise one row + * per named repo with that repo's task base_branch (or the workspace-level + * fallback when none was recorded). + */ +function buildBranchRows( + perRepoStatus: PerRepoStatus[], + baseBranchByRepo: Record | undefined, + baseBranchFallback: string, + repoDisplayName: ((name: string) => string | undefined) | undefined, +): BranchRow[] { + const named = perRepoStatus.filter((s) => s.repository_name !== "" && s.branch); + if (named.length <= 1) return []; + return named.map((s) => ({ + repoLabel: repoDisplayName?.(s.repository_name) || s.repository_name, + branch: s.branch ?? "", + baseBranch: baseBranchByRepo?.[s.repository_name] || baseBranchFallback, + })); +} + +function BranchRowView({ repoLabel, branch, baseBranch }: BranchRow) { + return ( +
    + {repoLabel && ( + + {repoLabel} + + )} + + + {branch} + +
    + + {baseBranch} +
    + ); +} + function BranchHoverCard({ displayBranch, baseBranchDisplay, + rows, }: { displayBranch: string; baseBranchDisplay: string; + /** When non-empty, the card renders one row per repo instead of the single + * workspace-level pair. Single-repo workspaces leave this undefined. */ + rows?: BranchRow[]; }) { + const isMulti = rows && rows.length > 0; + const headerLabel = isMulti ? "Your branches:" : "Your code lives in:"; + const trailerLabel = "and will be merged into:"; return ( @@ -40,40 +100,86 @@ function BranchHoverCard({
    - Your code lives in: - and will be merged into: -
    -
    - - - {displayBranch} - -
    - - {baseBranchDisplay} + {headerLabel} + {trailerLabel}
    + {isMulti ? ( +
    + {rows!.map((row) => ( + + ))} +
    + ) : ( + + )}
    ); } +function PullTriggerContent({ + behindCount, + isPulling, + isRebasing, +}: { + behindCount: number; + isPulling: boolean; + isRebasing: boolean; +}) { + const isPullRelated = isPulling || isRebasing; + let label: string; + if (isPulling) label = "Pulling…"; + else if (isRebasing) label = "Rebasing…"; + else label = "Pull"; + return ( + <> + {isPullRelated ? ( + + ) : ( + + )} + {label} + {behindCount > 0 && !isPullRelated && ( + {behindCount} + )} + {!isPullRelated && } + + ); +} + function PullDropdown({ behindCount, isLoading, loadingOperation, - onPull, - onRebase, + repoNames, + perRepoStatus, + onRepoPull, + onRepoRebase, + onRepoMerge, + repoDisplayName, }: { behindCount: number; isLoading: boolean; loadingOperation: string | null; - onPull: () => void; - onRebase: () => void; + /** Always non-empty (single-repo includes the empty-name entry). */ + repoNames: string[]; + perRepoStatus: PerRepoStatus[]; + onRepoPull: (repo: string) => void; + onRepoRebase: (repo: string) => void; + onRepoMerge: (repo: string) => void; + /** Maps a repository_name to its display label. */ + repoDisplayName?: (repositoryName: string) => string | undefined; }) { const isPulling = loadingOperation === "pull"; const isRebasing = loadingOperation === "rebase"; - const isPullRelated = isPulling || isRebasing; + // For single-repo (empty repo entry), the trigger label uses the global + // behindCount; for multi-repo we show the per-repo behinds inside the menu + // labels and the trigger summarises with the max. + const triggerBehind = + perRepoStatus.length > 0 + ? Math.max(behindCount, ...perRepoStatus.map((s) => s.behind)) + : behindCount; return ( @@ -83,67 +189,132 @@ function PullDropdown({ className="h-5 text-[11px] px-1.5 gap-1 cursor-pointer" disabled={isLoading} > - {isPullRelated ? ( - - ) : ( - - )} - {isPulling && "Pulling…"} - {!isPulling && isRebasing && "Rebasing…"} - {!isPulling && !isRebasing && "Pull"} - {behindCount > 0 && !isPullRelated && ( - {behindCount} - )} - {!isPullRelated && } + - - - - Pull - {behindCount > 0 && ( - {behindCount} behind - )} - - - - Rebase - {behindCount > 0 && ( - {behindCount} behind - )} - + + ); } +function PerRepoPullMenu({ + repoNames, + perRepoStatus, + onRepoPull, + onRepoRebase, + onRepoMerge, + repoDisplayName, +}: { + repoNames: string[]; + perRepoStatus: PerRepoStatus[]; + onRepoPull: (repo: string) => void; + onRepoRebase: (repo: string) => void; + onRepoMerge: (repo: string) => void; + repoDisplayName?: (repositoryName: string) => string | undefined; +}) { + const statusByName = new Map(perRepoStatus.map((s) => [s.repository_name, s])); + return ( + <> + {repoNames.map((repo, idx) => { + const s = statusByName.get(repo); + const behind = s?.behind ?? 0; + const label = repoDisplayName?.(repo) || repo || "Repository"; + return ( +
    + {idx > 0 && } + + {label} + {behind > 0 && ( + {behind} behind + )} + + onRepoPull(repo)} + className="cursor-pointer text-xs gap-2" + > + + Pull + + onRepoRebase(repo)} + className="cursor-pointer text-xs gap-2" + > + + Rebase + + onRepoMerge(repo)} + className="cursor-pointer text-xs gap-2" + > + + Merge + +
    + ); + })} + + ); +} + export function ChangesPanelHeader({ hasChanges, hasCommits, hasPRFiles, displayBranch, baseBranchDisplay, + baseBranchByRepo, behindCount, isLoading, loadingOperation, onOpenDiffAll, onOpenReview, - onPull, - onRebase, + repoNames, + perRepoStatus, + onRepoPull, + onRepoRebase, + onRepoMerge, + repoDisplayName, }: { hasChanges: boolean; hasCommits: boolean; hasPRFiles?: boolean; displayBranch: string | null; baseBranchDisplay: string; + /** Per-repo merge target, keyed by repository_name. Undefined entries fall + * back to baseBranchDisplay. Empty/missing for single-repo workspaces. */ + baseBranchByRepo?: Record; behindCount: number; isLoading: boolean; loadingOperation: string | null; onOpenDiffAll?: () => void; onOpenReview?: () => void; - onPull: () => void; - onRebase: () => void; + /** Always non-empty (single-repo includes the empty-name entry). */ + repoNames: string[]; + perRepoStatus: PerRepoStatus[]; + onRepoPull: (repo: string) => void; + onRepoRebase: (repo: string) => void; + onRepoMerge: (repo: string) => void; + repoDisplayName?: (repositoryName: string) => string | undefined; }) { + const branchRows = buildBranchRows( + perRepoStatus, + baseBranchByRepo, + baseBranchDisplay, + repoDisplayName, + ); const showDiffReview = hasChanges || hasCommits || !!hasPRFiles; return ( - {displayBranch && ( - + {(displayBranch || branchRows.length > 0) && ( + )} } diff --git a/apps/web/components/task/changes-panel-helpers.test.ts b/apps/web/components/task/changes-panel-helpers.test.ts new file mode 100644 index 0000000000..466a96b4a9 --- /dev/null +++ b/apps/web/components/task/changes-panel-helpers.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { mapPRFilesToChangedFiles } from "./changes-panel-helpers"; +import type { PRDiffFile } from "@/lib/types/github"; + +function diffFile(overrides: Partial): PRDiffFile { + return { + filename: "src/app.ts", + status: "modified", + additions: 1, + deletions: 0, + old_path: undefined, + ...overrides, + } as PRDiffFile; +} + +describe("mapPRFilesToChangedFiles", () => { + it("maps GitHub status strings to FileInfo statuses", () => { + const out = mapPRFilesToChangedFiles([ + diffFile({ filename: "a.ts", status: "added" }), + diffFile({ filename: "b.ts", status: "removed" }), + diffFile({ filename: "c.ts", status: "renamed", old_path: "old/c.ts" }), + diffFile({ filename: "d.ts", status: "modified" }), + // Anything not in the explicit set should fall through to "modified". + diffFile({ filename: "e.ts", status: "weird" as PRDiffFile["status"] }), + ]); + expect(out.map((f) => f.status)).toEqual([ + "added", + "deleted", + "renamed", + "modified", + "modified", + ]); + }); + + it("forwards path, additions, deletions, and old_path", () => { + const [out] = mapPRFilesToChangedFiles([ + diffFile({ + filename: "src/x.ts", + status: "renamed", + additions: 7, + deletions: 3, + old_path: "old/x.ts", + }), + ]); + expect(out.path).toBe("src/x.ts"); + expect(out.plus).toBe(7); + expect(out.minus).toBe(3); + expect(out.oldPath).toBe("old/x.ts"); + }); + + it("stamps repository_name on every row when supplied (multi-repo path)", () => { + const out = mapPRFilesToChangedFiles( + [diffFile({ filename: "a.ts" }), diffFile({ filename: "b.ts" })], + "frontend", + ); + expect(out.every((f) => f.repository_name === "frontend")).toBe(true); + }); + + it("defaults repository_name to '' when caller omits it (single-repo path)", () => { + // Empty string is meaningful: PRFilesGroupedList treats one group with + // empty name as the single-repo case and skips per-repo sub-headers. + const out = mapPRFilesToChangedFiles([diffFile({ filename: "a.ts" })]); + expect(out[0].repository_name).toBe(""); + }); + + it("returns an empty array for empty input", () => { + expect(mapPRFilesToChangedFiles([])).toEqual([]); + expect(mapPRFilesToChangedFiles([], "frontend")).toEqual([]); + }); +}); diff --git a/apps/web/components/task/changes-panel-helpers.ts b/apps/web/components/task/changes-panel-helpers.ts new file mode 100644 index 0000000000..56fb1a4392 --- /dev/null +++ b/apps/web/components/task/changes-panel-helpers.ts @@ -0,0 +1,205 @@ +"use client"; + +import { hashDiff, normalizeDiffContent } from "@/components/review/types"; +import type { FileInfo } from "@/lib/state/store"; +import type { PRDiffFile } from "@/lib/types/github"; +import type { PRChangedFile } from "./changes-panel-timeline"; + +export type ChangedFile = { + path: string; + status: FileInfo["status"]; + staged: boolean; + plus: number | undefined; + minus: number | undefined; + oldPath: string | undefined; + /** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */ + repositoryName?: string; +}; + +export function mapToChangedFiles(files: FileInfo[]): ChangedFile[] { + return files.map((file) => ({ + path: file.path, + status: file.status, + staged: file.staged, + plus: file.additions, + minus: file.deletions, + oldPath: file.old_path, + repositoryName: file.repository_name, + })); +} + +type CumulativeDiffFiles = Record< + string, + { diff?: string; status?: string; additions?: number; deletions?: number } +>; + +function collectReviewPaths( + uncommittedFiles: FileInfo[], + cumulativeDiffFiles: CumulativeDiffFiles | undefined, + prFiles?: PRDiffFile[], +): Set { + const paths = new Set(); + for (const file of uncommittedFiles) { + if (file.diff && normalizeDiffContent(file.diff)) paths.add(file.path); + } + if (cumulativeDiffFiles) { + for (const [path, file] of Object.entries(cumulativeDiffFiles)) { + if (!paths.has(path) && file.diff && normalizeDiffContent(file.diff)) paths.add(path); + } + } + if (prFiles) { + for (const file of prFiles) { + if (!paths.has(file.filename) && file.patch) paths.add(file.filename); + } + } + return paths; +} + +function getDiffForPath( + path: string, + uncommittedFiles: FileInfo[], + cumulativeDiffFiles: CumulativeDiffFiles | undefined, + prFiles?: PRDiffFile[], +): string { + const uncommitted = uncommittedFiles.find((f) => f.path === path); + if (uncommitted?.diff) return normalizeDiffContent(uncommitted.diff); + const cumDiff = cumulativeDiffFiles?.[path]?.diff; + if (cumDiff) return normalizeDiffContent(cumDiff); + if (prFiles) { + const prFile = prFiles.find((f) => f.filename === path); + if (prFile?.patch) return normalizeDiffContent(prFile.patch); + } + return ""; +} + +export function computeReviewProgress( + uncommittedFiles: FileInfo[], + cumulativeDiff: { files?: CumulativeDiffFiles } | null, + reviews: Map, + prFiles?: PRDiffFile[], +) { + const cumulativeDiffFiles = cumulativeDiff?.files; + const paths = collectReviewPaths(uncommittedFiles, cumulativeDiffFiles, prFiles); + let reviewed = 0; + for (const path of paths) { + const state = reviews.get(path); + if (!state?.reviewed) continue; + const diffContent = getDiffForPath(path, uncommittedFiles, cumulativeDiffFiles, prFiles); + if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue; + reviewed++; + } + return { reviewedCount: reviewed, totalFileCount: paths.size }; +} + +export function computeStagedStats(stagedFiles: FileInfo[]) { + const adds = stagedFiles.reduce((sum, f) => sum + (f.additions || 0), 0); + const dels = stagedFiles.reduce((sum, f) => sum + (f.deletions || 0), 0); + return { stagedFileCount: stagedFiles.length, stagedAdditions: adds, stagedDeletions: dels }; +} + +export function mapPRFilesToChangedFiles( + files: PRDiffFile[], + repositoryName?: string, +): PRChangedFile[] { + return files.map((file) => { + let status: FileInfo["status"]; + switch (file.status) { + case "added": + status = "added"; + break; + case "removed": + status = "deleted"; + break; + case "renamed": + status = "renamed"; + break; + default: + status = "modified"; + } + return { + path: file.filename, + status, + plus: file.additions, + minus: file.deletions, + oldPath: file.old_path, + repository_name: repositoryName ?? "", + }; + }); +} + +export function filterUnpushedCommits( + localCommits: T[], + prCommits: { sha: string }[], +): T[] { + if (prCommits.length === 0) return localCommits; + return localCommits.filter( + (c) => + !prCommits.some((pr) => pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)), + ); +} + +type MergedCommit = { + commit_sha: string; + commit_message: string; + insertions: number; + deletions: number; + pushed: boolean; + /** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */ + repository_name?: string; +}; + +/** + * Merge local session commits and PR commits into a single list. Local + * commits matched to a PR commit are marked pushed; unmatched are unpushed. + * PR-only commits (e.g. from other contributors) are appended as pushed. + * Order: unpushed first, then pushed. + */ +export function mergeCommits( + localCommits: { + commit_sha: string; + commit_message: string; + insertions: number; + deletions: number; + /** Multi-repo: name of the repo this commit was made in. */ + repository_name?: string; + }[], + prCommits: { sha: string; message: string; additions: number; deletions: number }[], +): MergedCommit[] { + const matchesPR = (localSha: string) => + prCommits.some((pr) => pr.sha.startsWith(localSha) || localSha.startsWith(pr.sha)); + const unpushed: MergedCommit[] = []; + const pushed: MergedCommit[] = []; + const matchedPRShas = new Set(); + for (const c of localCommits) { + if (matchesPR(c.commit_sha)) { + pushed.push({ ...c, pushed: true }); + for (const pr of prCommits) { + if (pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)) { + matchedPRShas.add(pr.sha); + } + } + } else { + unpushed.push({ ...c, pushed: false }); + } + } + for (const pr of prCommits) { + if (!matchedPRShas.has(pr.sha)) { + pushed.push({ + commit_sha: pr.sha, + commit_message: pr.message, + insertions: pr.additions, + deletions: pr.deletions, + pushed: true, + }); + } + } + return [...unpushed, ...pushed]; +} + +export function getBaseBranchDisplay(baseBranch: string | undefined): string { + return baseBranch ? baseBranch.replace(/^origin\//, "") : "main"; +} + +export function isMultiRepoCommits(commits: { repository_name?: string }[]): boolean { + return commits.some((c) => !!c.repository_name); +} diff --git a/apps/web/components/task/changes-panel-hooks.ts b/apps/web/components/task/changes-panel-hooks.ts index 8eb0ffd3cb..aee3f44a80 100644 --- a/apps/web/components/task/changes-panel-hooks.ts +++ b/apps/web/components/task/changes-panel-hooks.ts @@ -1,33 +1,78 @@ "use client"; import { useState, useCallback } from "react"; -import type { GitOperationResult, PRCreateResult } from "@/hooks/use-git-operations"; import type { useToast } from "@/components/toast-provider"; +import type { SessionGit, PerRepoOperationResult } from "@/hooks/domains/session/use-session-git"; -// Accepts both useGitOperations and SessionGit -interface GitOps { - pull: (rebase?: boolean) => Promise; - push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise; - rebase: (baseBranch: string) => Promise; - commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise; - stage: (paths?: string[]) => Promise; - unstage: (paths?: string[]) => Promise; - discard: (paths?: string[]) => Promise; - revertCommit: (commitSHA: string) => Promise; - reset: (commitSHA: string, mode: "soft" | "hard") => Promise; - createPR: ( - title: string, - body: string, - baseBranch?: string, - draft?: boolean, - ) => Promise; - isLoading: boolean; -} +// Bug 7: drop the local GitOps shape — `SessionGit` is the single source of +// truth for all the methods this module needs (pull, push, rebase, merge, +// commit, stage, unstage, discard, revertCommit, reset, createPR, isLoading). +// Callers pass the SessionGit returned by `useSessionGit` directly. +type GitOps = Pick< + SessionGit, + | "pull" + | "push" + | "rebase" + | "merge" + | "commit" + | "stage" + | "unstage" + | "discard" + | "revertCommit" + | "reset" + | "createPR" + | "isLoading" +>; type Toast = ReturnType["toast"]; -type GitOperationFn = ( - op: () => Promise<{ success: boolean; output: string; error?: string }>, - name: string, -) => Promise; +type GitOperationResultLike = { + success: boolean; + output: string; + error?: string; + per_repo?: PerRepoOperationResult[]; +}; +type GitOperationFn = (op: () => Promise, name: string) => Promise; + +/** + * Builds the toast description for a fan-out result. When `per_repo` is + * present, summarise per-repo successes/failures instead of returning the + * raw output (which was just the last repo's text and hid partial-success). + */ +function describePerRepo( + perRepo: PerRepoOperationResult[], + operationName: string, +): { title: string; description: string; variant: "success" | "error" } { + const succeeded = perRepo.filter((r) => r.success); + const failed = perRepo.filter((r) => !r.success); + const succeededNames = succeeded.map((r) => r.repository_name).join(", "); + const failedSummary = failed + .map((r) => `${r.repository_name}: ${r.error || "unknown error"}`) + .join("; "); + if (failed.length === 0) { + return { + title: `${operationName} successful`, + description: `${operationName} succeeded in ${succeeded.length} repos: ${succeededNames}`, + variant: "success", + }; + } + if (succeeded.length === 0) { + return { + title: `${operationName} failed`, + description: `Failed in ${failed.length} repos — ${failedSummary}`, + variant: "error", + }; + } + // Partial success: surface as error so the user notices, but include the + // succeeded list in the description so they don't retry the whole op. + return { + title: `${operationName} partially succeeded`, + description: `${operationName} succeeded in ${succeeded.length} of ${perRepo.length} repos (${succeededNames}); failed in ${failedSummary}`, + variant: "error", + }; +} + +function labelWithRepo(label: string, repo: string | undefined): string { + return repo ? `${label} (${repo})` : label; +} export function useChangesGitHandlers( gitOps: GitOps, @@ -35,12 +80,17 @@ export function useChangesGitHandlers( baseBranch: string | undefined, ) { const handleGitOperation = useCallback( - async ( - operation: () => Promise<{ success: boolean; output: string; error?: string }>, - operationName: string, - ) => { + async (operation: () => Promise, operationName: string) => { try { const result = await operation(); + // Bug 2: when the underlying op fanned out across multiple repos, + // describe the per-repo breakdown instead of the legacy flat + // success/error so partial successes are visible. + if (result.per_repo && result.per_repo.length > 1) { + const { title, description, variant } = describePerRepo(result.per_repo, operationName); + toast({ title, description, variant }); + return; + } const variant = result.success ? "success" : "error"; const title = result.success ? `${operationName} successful` : `${operationName} failed`; const description = result.success @@ -58,22 +108,44 @@ export function useChangesGitHandlers( [toast], ); - const handlePull = useCallback(() => { - handleGitOperation(() => gitOps.pull(), "Pull"); - }, [handleGitOperation, gitOps]); - const handleRebase = useCallback(() => { - const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; - handleGitOperation(() => gitOps.rebase(targetBranch), "Rebase"); - }, [handleGitOperation, gitOps, baseBranch]); - const handlePush = useCallback(() => { - handleGitOperation(() => gitOps.push(), "Push"); - }, [handleGitOperation, gitOps]); - const handleForcePush = useCallback(() => { - handleGitOperation(() => gitOps.push({ force: true }), "Force push"); - }, [handleGitOperation, gitOps]); + const handlePull = useCallback( + (repo?: string) => { + handleGitOperation(() => gitOps.pull(false, repo), labelWithRepo("Pull", repo)); + }, + [handleGitOperation, gitOps], + ); + const handleRebase = useCallback( + (repo?: string) => { + const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; + handleGitOperation(() => gitOps.rebase(targetBranch, repo), labelWithRepo("Rebase", repo)); + }, + [handleGitOperation, gitOps, baseBranch], + ); + const handleMerge = useCallback( + (repo?: string) => { + const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; + handleGitOperation(() => gitOps.merge(targetBranch, repo), labelWithRepo("Merge", repo)); + }, + [handleGitOperation, gitOps, baseBranch], + ); + const handlePush = useCallback( + (repo?: string) => { + handleGitOperation(() => gitOps.push(undefined, repo), labelWithRepo("Push", repo)); + }, + [handleGitOperation, gitOps], + ); + const handleForcePush = useCallback( + (repo?: string) => { + handleGitOperation( + () => gitOps.push({ force: true }, repo), + labelWithRepo("Force push", repo), + ); + }, + [handleGitOperation, gitOps], + ); const handleRevertCommit = useCallback( - (sha: string) => { - handleGitOperation(() => gitOps.revertCommit(sha), "Revert commit"); + (sha: string, repo?: string) => { + handleGitOperation(() => gitOps.revertCommit(sha, repo), "Revert commit"); }, [handleGitOperation, gitOps], ); @@ -82,6 +154,7 @@ export function useChangesGitHandlers( handleGitOperation, handlePull, handleRebase, + handleMerge, handlePush, handleForcePush, handleRevertCommit, @@ -96,22 +169,27 @@ function useChangesDiscardAmendHandlers( const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [fileToDiscard, setFileToDiscard] = useState(null); const [filesToDiscard, setFilesToDiscard] = useState(null); + // Multi-repo: remember the clicked file's repo so the discard op routes to + // the right git repo. Path alone is ambiguous when two repos share a name. + const [repoToDiscard, setRepoToDiscard] = useState(undefined); - const handleDiscardClick = useCallback((filePath: string) => { + const handleDiscardClick = useCallback((filePath: string, repo?: string) => { setFileToDiscard(filePath); + setRepoToDiscard(repo); setFilesToDiscard(null); setShowDiscardDialog(true); }, []); const handleBulkDiscardClick = useCallback((paths: string[]) => { setFilesToDiscard(paths); setFileToDiscard(null); + setRepoToDiscard(undefined); setShowDiscardDialog(true); }, []); const handleDiscardConfirm = useCallback(async () => { const paths = filesToDiscard ?? (fileToDiscard ? [fileToDiscard] : null); if (!paths) return; try { - const result = await gitOps.discard(paths); + const result = await gitOps.discard(paths, repoToDiscard); if (!result.success) toast({ title: "Failed to discard changes", @@ -128,24 +206,34 @@ function useChangesDiscardAmendHandlers( setShowDiscardDialog(false); setFileToDiscard(null); setFilesToDiscard(null); + setRepoToDiscard(undefined); } - }, [fileToDiscard, filesToDiscard, gitOps, toast]); + }, [fileToDiscard, filesToDiscard, repoToDiscard, gitOps, toast]); // Amend dialog state (for editing last commit message directly) const [amendDialogOpen, setAmendDialogOpen] = useState(false); const [amendMessage, setAmendMessage] = useState(""); + // Multi-repo: capture the commit's repo at click time so the amend lands in + // the right git repo. Path/SHA alone can't be disambiguated when each repo + // has its own HEAD. + const [amendRepo, setAmendRepo] = useState(undefined); - const handleOpenAmendDialog = useCallback((currentMessage: string) => { + const handleOpenAmendDialog = useCallback((currentMessage: string, repo?: string) => { setAmendMessage(currentMessage); + setAmendRepo(repo); setAmendDialogOpen(true); }, []); const handleAmend = useCallback(async () => { if (!amendMessage.trim()) return; setAmendDialogOpen(false); - await handleGitOperation(() => gitOps.commit(amendMessage.trim(), false, true), "Amend commit"); + await handleGitOperation( + () => gitOps.commit(amendMessage.trim(), false, true, amendRepo), + "Amend commit", + ); setAmendMessage(""); - }, [amendMessage, handleGitOperation, gitOps]); + setAmendRepo(undefined); + }, [amendMessage, amendRepo, handleGitOperation, gitOps]); return { showDiscardDialog, @@ -168,9 +256,13 @@ function useChangesDiscardAmendHandlers( function useChangesResetHandlers(gitOps: GitOps, handleGitOperation: GitOperationFn) { const [resetDialogOpen, setResetDialogOpen] = useState(false); const [resetCommitSha, setResetCommitSha] = useState(null); + // Multi-repo: capture the commit's repo so reset runs against the right + // git repo. Without it, reset hits the workspace root and fails. + const [resetRepo, setResetRepo] = useState(undefined); - const handleOpenResetDialog = useCallback((sha: string) => { + const handleOpenResetDialog = useCallback((sha: string, repo?: string) => { setResetCommitSha(sha); + setResetRepo(repo); setResetDialogOpen(true); }, []); @@ -179,10 +271,11 @@ function useChangesResetHandlers(gitOps: GitOps, handleGitOperation: GitOperatio if (!resetCommitSha) return; setResetDialogOpen(false); const operationName = mode === "hard" ? "Hard reset" : "Soft reset"; - await handleGitOperation(() => gitOps.reset(resetCommitSha, mode), operationName); + await handleGitOperation(() => gitOps.reset(resetCommitSha, mode, resetRepo), operationName); setResetCommitSha(null); + setResetRepo(undefined); }, - [resetCommitSha, handleGitOperation, gitOps], + [resetCommitSha, resetRepo, handleGitOperation, gitOps], ); return { diff --git a/apps/web/components/task/changes-panel-pr-files.tsx b/apps/web/components/task/changes-panel-pr-files.tsx new file mode 100644 index 0000000000..ff630b019e --- /dev/null +++ b/apps/web/components/task/changes-panel-pr-files.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +import { IconChevronDown, IconChevronRight, IconGitPullRequest } from "@tabler/icons-react"; +import { LineStat } from "@/components/diff-stat"; +import { FileStatusIcon } from "./file-status-icon"; +import { groupByRepositoryName } from "@/lib/group-by-repo"; +import type { PRChangedFile } from "./changes-panel-timeline"; + +type PRFilesSectionContentProps = { + files: PRChangedFile[]; + onOpenDiff: (path: string) => void; + /** Maps a repository_name to a human-readable label for the per-repo header. */ + repoDisplayName?: (repositoryName: string) => string | undefined; +}; + +/** + * Body of the PR Changes section: groups files by `repository_name` so + * multi-repo tasks render one sub-header per repo, mirroring the Commits + * and Changes sections. Single-repo tasks (one group with empty name) + * render the file list flat — the section header is enough. + */ +export function PRFilesGroupedList({ + files, + onOpenDiff, + repoDisplayName, +}: PRFilesSectionContentProps) { + const groups = groupByRepositoryName(files, (f) => f.repository_name); + const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== ""; + return ( +
      + {groups.map((group) => ( + + ))} +
    + ); +} + +function PRFilesRepoGroup({ + repositoryName, + displayName, + files, + showHeader, + onOpenDiff, +}: { + repositoryName: string; + displayName?: string; + files: PRChangedFile[]; + showHeader: boolean; + onOpenDiff: (path: string) => void; +}) { + const [collapsed, setCollapsed] = useState(false); + const label = displayName ?? repositoryName ?? ""; + return ( +
  • + {showHeader && ( + + )} + {!collapsed && ( +
      + {files.map((file) => ( + + ))} +
    + )} +
  • + ); +} + +function PRFileRow({ + file, + onOpenDiff, +}: { + file: PRChangedFile; + onOpenDiff: (path: string) => void; +}) { + const lastSlash = file.path.lastIndexOf("/"); + const folder = lastSlash === -1 ? "" : file.path.slice(0, lastSlash); + const name = lastSlash === -1 ? file.path : file.path.slice(lastSlash + 1); + + return ( +
  • onOpenDiff(file.path)} + > +
    +
    + +
    + +
    +
    + + +
    +
  • + ); +} diff --git a/apps/web/components/task/changes-panel-repo-groups.tsx b/apps/web/components/task/changes-panel-repo-groups.tsx new file mode 100644 index 0000000000..955b0ac715 --- /dev/null +++ b/apps/web/components/task/changes-panel-repo-groups.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useState } from "react"; +import { + IconChevronDown, + IconChevronRight, + IconCloudUpload, + IconGitPullRequest, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { CommitRow, type CommitItem } from "./commit-row"; +import { groupByRepositoryName } from "@/lib/group-by-repo"; + +type ChangedFile = { + path: string; + status: import("@/lib/state/store").FileInfo["status"]; + staged: boolean; + plus: number | undefined; + minus: number | undefined; + oldPath: string | undefined; + repositoryName?: string; +}; + +export type RepoGroup = ReturnType>[number]; + +/** + * Per-repo group inside a file-list section (Unstaged / Staged). Renders the + * collapsible repo header with optional inline action buttons (Stage all / + * Commit / Unstage all). Action labels and handlers are owned by the parent + * section so the same shape works for both variants. + */ +export function RepoGroupItem({ + group, + collapsed, + onToggle, + renderRow, + primaryLabel, + secondaryLabel, + onRepoAction, + onRepoSecondaryAction, + displayName, +}: { + group: RepoGroup; + collapsed: boolean; + onToggle: () => void; + renderRow: (file: ChangedFile) => React.ReactNode; + primaryLabel: string; + secondaryLabel?: string; + onRepoAction?: (repo: string) => void; + onRepoSecondaryAction?: (repo: string) => void; + /** Optional display label override; defaults to group.repositoryName. */ + displayName?: string; +}) { + const stop = (e: React.MouseEvent) => e.stopPropagation(); + const label = displayName || group.repositoryName || "Repository"; + return ( +
  • +
    + + {(onRepoAction || onRepoSecondaryAction) && ( +
    + {onRepoAction && ( + + )} + {onRepoSecondaryAction && secondaryLabel && ( + + )} +
    + )} +
    + {!collapsed &&
      {group.items.map(renderRow)}
    } +
  • + ); +} + +function CommitsGroupActions({ + repositoryName, + unpushedCount, + aheadCount, + prExists, + canCreatePR, + onRepoPush, + onRepoCreatePR, + stop, +}: { + repositoryName: string; + unpushedCount: number; + aheadCount: number; + prExists: boolean; + canCreatePR: boolean; + onRepoPush?: (repo: string) => void; + onRepoCreatePR?: (repo: string) => void; + stop: (e: React.MouseEvent) => void; +}) { + return ( +
    + {onRepoPush && (unpushedCount > 0 || aheadCount > 0) && ( + + )} + {onRepoCreatePR && ( + + + + + {prExists && A pull request already exists for this task} + + )} +
    + ); +} + +/** + * Per-repo group inside the Commits section. Like {@link RepoGroupItem} but + * for commit rows; surfaces a Push button when the repo has unpushed commits. + */ +export function CommitsRepoGroup({ + repositoryName, + displayName, + groupCommits, + aheadCount = 0, + existingPrUrl, + onOpenCommitDetail, + onAmendCommit, + onRevertCommit, + onResetToCommit, + onRepoPush, + onRepoCreatePR, +}: { + repositoryName: string; + displayName?: string; + groupCommits: CommitItem[]; + aheadCount?: number; + existingPrUrl?: string; + onOpenCommitDetail?: (sha: string, repo?: string) => void; + onAmendCommit?: (currentMessage: string, repo?: string) => void; + onRevertCommit?: (sha: string, repo?: string) => void; + onResetToCommit?: (sha: string, repo?: string) => void; + onRepoPush?: (repo: string) => void; + onRepoCreatePR?: (repo: string) => void; + /** Base branch passed through; reserved for richer per-repo PR UX. */ + baseBranch?: string; +}) { + const [collapsed, setCollapsed] = useState(false); + // Each repo has its own "latest unpushed commit" — revert/amend in this + // group must target THIS repo's newest, not the merged-list newest. + const firstUnpushedInGroup = groupCommits.findIndex((c) => c.pushed !== true); + const unpushedCount = groupCommits.filter((c) => !c.pushed).length; + const stop = (e: React.MouseEvent) => e.stopPropagation(); + const label = displayName || repositoryName || "Repository"; + // Bug 10 acknowledged trade-off: `existingPrUrl` is sourced from a + // workspace-scoped `prByRepo` map keyed only by "" today — the kandev task + // model has one PR per task, not one PR per repo. As a result every per- + // repo group inherits the same PR URL. When the backend grows per-repo PR + // tracking, callers should key `prByRepo` by `repositoryName` and the + // `?? prByRepo[""]` fallback in CommitsSection should be removed so each + // group surfaces only its own PR. Until then, the visual is "PR exists for + // this task" rather than "PR exists for this repo" — accept the imprecision. + const prExists = !!existingPrUrl; + // The PR scope today is workspace-wide (one PR per task). Disable per-repo + // Create PR once a PR exists; the user can update it via push instead. + const canCreatePR = !!onRepoCreatePR && !prExists; + return ( +
  • +
    + + +
    + {!collapsed && ( +
      + {groupCommits.map((commit, index) => ( + + ))} +
    + )} +
  • + ); +} diff --git a/apps/web/components/task/changes-panel-timeline-grouping.test.tsx b/apps/web/components/task/changes-panel-timeline-grouping.test.tsx new file mode 100644 index 0000000000..8d83cfbd3d --- /dev/null +++ b/apps/web/components/task/changes-panel-timeline-grouping.test.tsx @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, within } from "@testing-library/react"; +import type { ComponentProps } from "react"; + +vi.mock("./changes-panel-file-row", () => ({ + FileRow: ({ file }: { file: { path: string } }) =>
  • {file.path}
  • , + BulkActionBar: () => null, + DefaultActionButtons: () => null, +})); + +vi.mock("./commit-row", () => ({ + CommitRow: ({ commit, isLatest }: { commit: { commit_sha: string }; isLatest: boolean }) => ( +
  • + {commit.commit_sha} +
  • + ), +})); + +vi.mock("@/hooks/use-multi-select", () => ({ + useMultiSelect: () => ({ + selectedPaths: new Set(), + isSelected: () => false, + handleClick: vi.fn(), + clearSelection: vi.fn(), + }), +})); + +import { FileListSection, CommitsSection } from "./changes-panel-timeline"; + +const REPO_HEADER_TID = "changes-repo-header"; +const COMMIT_ROW_TID = "commit-row"; +const COMMITS_SECTION_TOGGLE_TID = "commits-section-collapse-toggle"; + +afterEach(cleanup); + +type Props = ComponentProps; + +const baseProps: Omit = { + pendingStageFiles: new Set(), + onOpenDiff: vi.fn(), + onEditFile: vi.fn(), + onStage: vi.fn(), + onUnstage: vi.fn(), + onDiscard: vi.fn(), +}; + +function file(path: string, repo?: string): Props["files"][number] { + return { + path, + status: "modified", + staged: false, + plus: 1, + minus: 0, + oldPath: undefined, + repositoryName: repo, + }; +} + +describe("FileListSection — multi-repo grouping", () => { + it("renders a single repo header (uniform UI) for files without a repository_name", () => { + // Per-repo headers always render now, including in single-repo / untagged + // mode — the resolver fills in the workspace primary repo name (or a + // neutral "Repository" fallback). This keeps the UX consistent across + // single-repo and multi-repo workspaces and gives us per-repo Stage all / + // Commit / Unstage all buttons in both modes. + render( + undefined} + files={[file("a.ts"), file("b.ts")]} + />, + ); + expect(screen.getAllByTestId(REPO_HEADER_TID)).toHaveLength(1); + expect(screen.getAllByTestId("file-row")).toHaveLength(2); + }); + + it("renders one header per repo when 2+ repos are present", () => { + render( + undefined} + files={[ + file("src/app.tsx", "frontend"), + file("src/api.ts", "frontend"), + file("handlers/task.go", "backend"), + ]} + />, + ); + const headers = screen.getAllByTestId(REPO_HEADER_TID); + expect(headers).toHaveLength(2); + expect(headers[0].textContent).toContain("frontend"); + expect(headers[0].textContent).toContain("2"); + expect(headers[1].textContent).toContain("backend"); + expect(headers[1].textContent).toContain("1"); + }); + + it("shows a header for a single named repo too", () => { + render( + undefined} + files={[file("a.ts", "only-repo")]} + />, + ); + expect(screen.getByTestId(REPO_HEADER_TID).textContent).toContain("only-repo"); + }); + + it("collapses one repo independently when its header is clicked", () => { + render( + undefined} + files={[ + file("src/app.tsx", "frontend"), + file("src/api.ts", "frontend"), + file("handlers/task.go", "backend"), + ]} + />, + ); + + expect(screen.getAllByTestId("file-row")).toHaveLength(3); + + const groups = screen.getAllByTestId("changes-repo-group"); + const frontendGroup = groups.find( + (g) => g.getAttribute("data-repository-name") === "frontend", + )!; + const frontendHeader = within(frontendGroup).getByTestId(REPO_HEADER_TID); + + fireEvent.click(frontendHeader); + + // frontend's two rows hidden, backend's one row still visible + expect(screen.getAllByTestId("file-row")).toHaveLength(1); + expect(frontendHeader.getAttribute("aria-expanded")).toBe("false"); + + fireEvent.click(frontendHeader); + expect(screen.getAllByTestId("file-row")).toHaveLength(3); + expect(frontendHeader.getAttribute("aria-expanded")).toBe("true"); + }); +}); + +describe("CommitsSection", () => { + type CommitProps = ComponentProps; + + function commit(sha: string, message: string, repo?: string): CommitProps["commits"][number] { + return { + commit_sha: sha, + commit_message: message, + insertions: 1, + deletions: 0, + pushed: false, + repository_name: repo, + }; + } + + it("renders the commits section header with a collapse toggle", () => { + render(); + // Section is expanded by default — file changes and commit history are + // both first-class signals in the changes panel. + expect(screen.getByTestId(COMMIT_ROW_TID)).toBeTruthy(); + const toggle = screen.getByTestId(COMMITS_SECTION_TOGGLE_TID); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + }); + + it("groups commits per repo when 2+ repos are present", () => { + render( + , + ); + const headers = screen.getAllByTestId("commits-repo-header"); + expect(headers).toHaveLength(2); + expect(headers[0].textContent).toContain("frontend"); + expect(headers[0].textContent).toContain("2"); + expect(headers[1].textContent).toContain("backend"); + expect(headers[1].textContent).toContain("1"); + expect(screen.getAllByTestId(COMMIT_ROW_TID)).toHaveLength(3); + }); + + it("renders a single commits-repo header (uniform UI) for untagged commits", () => { + // The Commits section, like the file lists, always groups by repo now. + // For untagged or single-repo commits we get one group with the empty + // repository_name; the header still renders so the per-repo Push / PR + // buttons live in a consistent place across single-repo and multi-repo. + render(); + expect(screen.getAllByTestId("commits-repo-header")).toHaveLength(1); + expect(screen.getAllByTestId(COMMIT_ROW_TID)).toHaveLength(2); + }); + + // Regression: previously isLatest was computed against the merged list, so + // only ONE commit globally was marked latest. Each repo's newest unpushed + // commit must be latest in its own group — otherwise revert/amend buttons + // are absent on every repo except one, and clicking revert via context menu + // hits the backend's "can only revert latest commit" gate. + it("marks each repo's newest unpushed commit as latest (per-repo, not global)", () => { + render( + undefined} + />, + ); + const rows = screen.getAllByTestId(COMMIT_ROW_TID); + const latestByShas = rows + .filter((r) => r.getAttribute("data-is-latest") === "true") + .map((r) => r.getAttribute("data-sha")); + expect(latestByShas.sort()).toEqual(["backend-only", "frontend-new"]); + }); +}); diff --git a/apps/web/components/task/changes-panel-timeline.tsx b/apps/web/components/task/changes-panel-timeline.tsx index 1414ed280f..0d03f86c18 100644 --- a/apps/web/components/task/changes-panel-timeline.tsx +++ b/apps/web/components/task/changes-panel-timeline.tsx @@ -20,10 +20,11 @@ import { } from "@kandev/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { FileInfo } from "@/lib/state/store"; -import { LineStat } from "@/components/diff-stat"; -import { FileStatusIcon } from "./file-status-icon"; -import { FileRow, BulkActionBar, DefaultActionButtons } from "./changes-panel-file-row"; -import { CommitRow, type CommitItem } from "./commit-row"; +import { FileRow, BulkActionBar } from "./changes-panel-file-row"; +import { type CommitItem } from "./commit-row"; +import { groupByRepositoryName } from "@/lib/group-by-repo"; +import { CommitsRepoGroup, RepoGroupItem } from "./changes-panel-repo-groups"; +import { PRFilesGroupedList } from "./changes-panel-pr-files"; // --- Timeline visual components --- @@ -34,8 +35,12 @@ type ChangedFile = { plus: number | undefined; minus: number | undefined; oldPath: string | undefined; + /** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */ + repositoryName?: string; }; +// Per-repo grouping is shared with the Review dialog — see @/lib/group-by-repo. + // --- Timeline section dot colors --- const DOT_COLORS = { unstaged: "bg-yellow-500", @@ -57,6 +62,7 @@ function TimelineSection({ isLast, children, collapsible = true, + defaultCollapsed = false, "data-testid": testId, }: { dotColor: string; @@ -66,9 +72,10 @@ function TimelineSection({ isLast?: boolean; children?: React.ReactNode; collapsible?: boolean; + defaultCollapsed?: boolean; "data-testid"?: string; }) { - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(defaultCollapsed); const canCollapse = collapsible && !!label; return ( @@ -126,12 +133,29 @@ function TimelineSection({ type CommitsSectionProps = { commits: CommitItem[]; isLast: boolean; - onOpenCommitDetail?: (sha: string) => void; - onRevertCommit?: (sha: string) => void; - onAmendCommit?: (currentMessage: string) => void; - onResetToCommit?: (sha: string) => void; + onOpenCommitDetail?: (sha: string, repo?: string) => void; + // Handlers receive the commit's repository_name so amend/revert/reset land + // in the right git repo. The empty string routes to the workspace root for + // single-repo workspaces. + onRevertCommit?: (sha: string, repo?: string) => void; + onAmendCommit?: (currentMessage: string, repo?: string) => void; + onResetToCommit?: (sha: string, repo?: string) => void; + /** Per-repo Push button rendered in each commits group header. */ + onRepoPush?: (repo: string) => void; + /** Per-repo Create PR button rendered in each commits group header. */ + onRepoCreatePR?: (repo: string) => void; + /** Maps a repository_name to its display label (used for the empty single-repo case). */ + repoDisplayName?: (repositoryName: string) => string | undefined; + /** The session's base branch — passed through to the per-repo PR dialog. */ + repoBaseBranch?: string; + /** Per-repo branch / ahead / behind summary; used for the "ahead" indicator. */ + perRepoStatus?: Array<{ repository_name: string; ahead: number }>; + /** Existing PR URL keyed by repository_name; "" key for single-repo. */ + prByRepo?: Record; }; +// Commits grouping shares the helper above with files — see @/lib/group-by-repo. + export function CommitsSection({ commits, isLast, @@ -139,9 +163,18 @@ export function CommitsSection({ onRevertCommit, onAmendCommit, onResetToCommit, + onRepoPush, + onRepoCreatePR, + repoDisplayName, + repoBaseBranch, + perRepoStatus, + prByRepo, }: CommitsSectionProps) { - // Find the first unpushed commit for context menu actions (amend/revert only on latest unpushed) - const firstUnpushedIndex = commits.findIndex((c) => c.pushed !== true); + // Always group by repo. Single-repo shows up as one group with an empty + // repository_name; the group header still renders with a display label and + // per-repo buttons (Push, Create PR) so the UX is uniform across modes. + const groups = groupByRepositoryName(commits, (c) => c.repository_name); + const aheadByRepo = new Map((perRepoStatus ?? []).map((s) => [s.repository_name, s.ahead])); return (
      - {commits.map((commit, index) => ( - ( + ))}
    @@ -181,6 +220,8 @@ type ActionButtonsSectionProps = { canCreatePR: boolean; existingPrUrl?: string; isLast?: boolean; + /** Hide the Push button (per-repo Push is rendered elsewhere in multi-repo). */ + hidePush?: boolean; }; export function ActionButtonsSection({ @@ -194,6 +235,7 @@ export function ActionButtonsSection({ canCreatePR, existingPrUrl, isLast = true, + hidePush = false, }: ActionButtonsSectionProps) { const prExists = !!existingPrUrl; const createPrDisabled = !canCreatePR || prExists || isLoading; @@ -227,48 +269,53 @@ export function ActionButtonsSection({ {prExists && A pull request already exists for this task} - - - - - - - - - - - - Force Push (with lease) - - - - - - {pushTooltip && {pushTooltip}} - + {!hidePush && ( + + + + + + + + + + + + Force Push (with lease) + + + + + + {pushTooltip && {pushTooltip}} + + )}
    ); @@ -289,14 +336,107 @@ type FileListSectionProps = { onSecondaryAction?: () => void; onOpenDiff: (path: string) => void; onEditFile: (path: string) => void; - onStage: (path: string) => void; - onUnstage: (path: string) => void; - onDiscard: (path: string) => void; + // Multi-repo: handlers receive the file's repositoryName so each per-file op + // hits the right git repo. Same-named files across repos collide by path. + onStage: (path: string, repo?: string) => void; + onUnstage: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string) => void; onBulkStage?: (paths: string[]) => void; onBulkUnstage?: (paths: string[]) => void; onBulkDiscard?: (paths: string[]) => void; + // Per-repo action handlers shown inline with each repo group header. Always + // rendered, including single-repo (with one entry pre-selected) — the empty + // `repo` argument routes ops to the workspace root in that case. + onRepoAction?: (repo: string) => void; + onRepoSecondaryAction?: (repo: string) => void; + /** Maps a repository_name to its display label; called per group header. */ + repoDisplayName?: (repositoryName: string) => string | undefined; }; +/** + * Renders the body of a file-list section. For multi-repo workspaces it + * groups files by repository under per-repo subheaders; single-repo + * workspaces fall back to a flat list (no header) so the existing UI is + * unchanged. + */ +type FileListBodyProps = { + variant: "unstaged" | "staged"; + files: ChangedFile[]; + pendingStageFiles: Set; + multiSelect: ReturnType; + onKeyDown: (e: React.KeyboardEvent) => void; + onOpenDiff: (path: string) => void; + onEditFile: (path: string) => void; + onStage: (path: string, repo?: string) => void; + onUnstage: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string) => void; + /** Per-repo group header actions; primary = Stage all (unstaged) / Commit (staged). */ + onRepoAction?: (repo: string) => void; + onRepoSecondaryAction?: (repo: string) => void; + primaryLabel: string; + secondaryLabel?: string; + /** Maps a repository_name to its display label (e.g. "" → workspace primary repo name). */ + repoDisplayName?: (repositoryName: string) => string | undefined; +}; + +function FileListBody(props: FileListBodyProps) { + const { variant, files, pendingStageFiles, multiSelect } = props; + const groups = useMemo(() => groupByRepositoryName(files, (f) => f.repositoryName), [files]); + // Per-repo collapsed state: keyed by repositoryName. Default expanded; + // setting an entry to true collapses that group. Persists across re-renders + // for the lifetime of this section instance (resets on unmount). + const [collapsedRepos, setCollapsedRepos] = useState>(() => new Set()); + const toggleRepo = useCallback((name: string) => { + setCollapsedRepos((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }, []); + + const renderRow = (file: ChangedFile) => ( + + ); + + return ( +
    +
      + {groups.map((group) => ( + toggleRepo(group.repositoryName)} + renderRow={renderRow} + primaryLabel={props.primaryLabel} + secondaryLabel={props.secondaryLabel} + onRepoAction={props.onRepoAction} + onRepoSecondaryAction={props.onRepoSecondaryAction} + displayName={props.repoDisplayName?.(group.repositoryName)} + /> + ))} +
    +
    + ); +} + export function FileListSection(props: FileListSectionProps) { const { variant, @@ -315,7 +455,9 @@ export function FileListSection(props: FileListSectionProps) { const filePaths = useMemo(() => files.map((f) => f.path), [files]); const multiSelect = useMultiSelect({ items: filePaths }); const hasSelection = multiSelect.selectedPaths.size > 0; - + // Multi-repo when any file has a repositoryName. Per-repo group headers + // own the action buttons in this case; the bottom-of-section global + // buttons would be redundant, so we skip them. const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape" && hasSelection) multiSelect.clearSelection(); @@ -332,51 +474,34 @@ export function FileListSection(props: FileListSectionProps) { data-testid={`${variant}-files-section`} > {files.length > 0 && ( -
    -
      - {files.map((file) => ( - - ))} -
    -
    + )} - {files.length > 0 && ( + {files.length > 0 && hasSelection && (
    - {hasSelection ? ( - - ) : ( - - )} +
    )} @@ -391,15 +516,29 @@ export type PRChangedFile = { plus: number | undefined; minus: number | undefined; oldPath: string | undefined; + /** + * The repository the file belongs to. Empty string for single-repo tasks + * (the section header alone is enough). Multi-repo tasks stamp this so + * `PRFilesSection` can group rows under per-repo subheaders, mirroring + * how the Commits and Changes sections render. + */ + repository_name?: string; }; type PRFilesSectionProps = { files: PRChangedFile[]; isLast: boolean; onOpenDiff: (path: string) => void; + /** Maps a repository_name to a human-readable label (used for the per-repo header). */ + repoDisplayName?: (repositoryName: string) => string | undefined; }; -export function PRFilesSection({ files, isLast, onOpenDiff }: PRFilesSectionProps) { +export function PRFilesSection({ + files, + isLast, + onOpenDiff, + repoDisplayName, +}: PRFilesSectionProps) { return ( {files.length > 0 && ( -
      - {files.map((file) => ( - - ))} -
    + )}
    ); } -function PRFileRow({ - file, - onOpenDiff, -}: { - file: PRChangedFile; - onOpenDiff: (path: string) => void; -}) { - const lastSlash = file.path.lastIndexOf("/"); - const folder = lastSlash === -1 ? "" : file.path.slice(0, lastSlash); - const name = lastSlash === -1 ? file.path : file.path.slice(lastSlash + 1); - - return ( -
  • onOpenDiff(file.path)} - > -
    -
    - -
    - -
    -
    - - -
    -
  • - ); -} - // --- Review progress bar --- type ReviewProgressBarProps = { diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index c31344b422..13e2491347 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -6,8 +6,6 @@ import { useAppStore } from "@/components/state-provider"; import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useSessionFileReviews } from "@/hooks/use-session-file-reviews"; import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; -import { hashDiff, normalizeDiffContent } from "@/components/review/types"; -import type { FileInfo } from "@/lib/state/store"; import { useToast } from "@/components/toast-provider"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; import { DiscardDialog, AmendDialog, ResetDialog } from "./changes-panel-dialogs"; @@ -16,215 +14,38 @@ import { ChangesPanelHeader } from "./changes-panel-header"; import { FileListSection, CommitsSection, - ActionButtonsSection, ReviewProgressBar, PRFilesSection, } from "./changes-panel-timeline"; import type { PRChangedFile } from "./changes-panel-timeline"; -import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks"; -import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; -import { usePRDiff } from "@/hooks/domains/github/use-pr-diff"; -import { usePRCommits } from "@/hooks/domains/github/use-pr-commits"; import type { PRDiffFile } from "@/lib/types/github"; +import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks"; +import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; +import { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo"; +import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { useActiveTaskPRsWithFiles } from "@/hooks/domains/github/use-active-task-pr-files"; +import { usePRCommits } from "@/hooks/domains/github/use-pr-commits"; +import { + type ChangedFile, + computeReviewProgress, + computeStagedStats, + filterUnpushedCommits, + getBaseBranchDisplay, + mapPRFilesToChangedFiles, + mapToChangedFiles, + mergeCommits, +} from "./changes-panel-helpers"; + +export { filterUnpushedCommits, mergeCommits }; type ChangesPanelProps = { onOpenDiffFile: (path: string) => void; onEditFile: (path: string) => void; - onOpenCommitDetail?: (sha: string) => void; + onOpenCommitDetail?: (sha: string, repo?: string) => void; onOpenDiffAll?: () => void; onOpenReview?: () => void; }; -// Maps FileInfo (store) to the display format expected by FileListSection -type ChangedFile = { - path: string; - status: FileInfo["status"]; - staged: boolean; - plus: number | undefined; - minus: number | undefined; - oldPath: string | undefined; -}; - -function mapToChangedFiles(files: FileInfo[]): ChangedFile[] { - return files.map((file) => ({ - path: file.path, - status: file.status, - staged: file.staged, - plus: file.additions, - minus: file.deletions, - oldPath: file.old_path, - })); -} - -type CumulativeDiffFiles = Record< - string, - { diff?: string; status?: string; additions?: number; deletions?: number } ->; - -function collectReviewPaths( - uncommittedFiles: FileInfo[], - cumulativeDiffFiles: CumulativeDiffFiles | undefined, - prFiles?: PRDiffFile[], -): Set { - const paths = new Set(); - for (const file of uncommittedFiles) { - if (file.diff && normalizeDiffContent(file.diff)) paths.add(file.path); - } - if (cumulativeDiffFiles) { - for (const [path, file] of Object.entries(cumulativeDiffFiles)) { - if (!paths.has(path) && file.diff && normalizeDiffContent(file.diff)) paths.add(path); - } - } - if (prFiles) { - for (const file of prFiles) { - if (!paths.has(file.filename) && file.patch) paths.add(file.filename); - } - } - return paths; -} - -function getDiffForPath( - path: string, - uncommittedFiles: FileInfo[], - cumulativeDiffFiles: CumulativeDiffFiles | undefined, - prFiles?: PRDiffFile[], -): string { - const uncommitted = uncommittedFiles.find((f) => f.path === path); - if (uncommitted?.diff) return normalizeDiffContent(uncommitted.diff); - const cumDiff = cumulativeDiffFiles?.[path]?.diff; - if (cumDiff) return normalizeDiffContent(cumDiff); - if (prFiles) { - const prFile = prFiles.find((f) => f.filename === path); - if (prFile?.patch) return normalizeDiffContent(prFile.patch); - } - return ""; -} - -function computeReviewProgress( - uncommittedFiles: FileInfo[], - cumulativeDiff: { files?: CumulativeDiffFiles } | null, - reviews: Map, - prFiles?: PRDiffFile[], -) { - const cumulativeDiffFiles = cumulativeDiff?.files; - const paths = collectReviewPaths(uncommittedFiles, cumulativeDiffFiles, prFiles); - let reviewed = 0; - for (const path of paths) { - const state = reviews.get(path); - if (!state?.reviewed) continue; - const diffContent = getDiffForPath(path, uncommittedFiles, cumulativeDiffFiles, prFiles); - if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue; - reviewed++; - } - return { reviewedCount: reviewed, totalFileCount: paths.size }; -} - -function computeStagedStats(stagedFiles: FileInfo[]) { - const adds = stagedFiles.reduce((sum, f) => sum + (f.additions || 0), 0); - const dels = stagedFiles.reduce((sum, f) => sum + (f.deletions || 0), 0); - return { stagedFileCount: stagedFiles.length, stagedAdditions: adds, stagedDeletions: dels }; -} - -function mapPRFilesToChangedFiles(files: PRDiffFile[]): PRChangedFile[] { - return files.map((file) => { - let status: FileInfo["status"]; - switch (file.status) { - case "added": - status = "added"; - break; - case "removed": - status = "deleted"; - break; - case "renamed": - status = "renamed"; - break; - default: - status = "modified"; - } - return { - path: file.filename, - status, - plus: file.additions, - minus: file.deletions, - oldPath: file.old_path, - }; - }); -} - -export function filterUnpushedCommits( - localCommits: T[], - prCommits: { sha: string }[], -): T[] { - if (prCommits.length === 0) return localCommits; - return localCommits.filter( - (c) => - !prCommits.some((pr) => pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)), - ); -} - -type MergedCommit = { - commit_sha: string; - commit_message: string; - insertions: number; - deletions: number; - pushed: boolean; -}; - -/** - * Merge local session commits and PR commits into a single list. - * Local commits matched to a PR commit are marked pushed; unmatched are unpushed. - * PR-only commits (e.g. from other contributors) are appended as pushed. - * Order: unpushed first, then pushed. - */ -export function mergeCommits( - localCommits: { - commit_sha: string; - commit_message: string; - insertions: number; - deletions: number; - }[], - prCommits: { sha: string; message: string; additions: number; deletions: number }[], -): MergedCommit[] { - const matchesPR = (localSha: string) => - prCommits.some((pr) => pr.sha.startsWith(localSha) || localSha.startsWith(pr.sha)); - - const unpushed: MergedCommit[] = []; - const pushed: MergedCommit[] = []; - const matchedPRShas = new Set(); - - for (const c of localCommits) { - if (matchesPR(c.commit_sha)) { - pushed.push({ ...c, pushed: true }); - for (const pr of prCommits) { - if (pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)) { - matchedPRShas.add(pr.sha); - } - } - } else { - unpushed.push({ ...c, pushed: false }); - } - } - - // Add PR-only commits (not matched to any local commit) - for (const pr of prCommits) { - if (!matchedPRShas.has(pr.sha)) { - pushed.push({ - commit_sha: pr.sha, - commit_message: pr.message, - insertions: pr.additions, - deletions: pr.deletions, - pushed: true, - }); - } - } - - return [...unpushed, ...pushed]; -} - -function getBaseBranchDisplay(baseBranch: string | undefined): string { - return baseBranch ? baseBranch.replace(/^origin\//, "") : "main"; -} - function useChangesPanelStoreData() { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); // Use environment-stable sessionId so git hooks (commits, cumulative diff) @@ -240,7 +61,9 @@ function useChangesPanelStoreData() { const existingPrUrl = useAppStore((state) => { const taskId = state.tasks.activeTaskId; if (!taskId) return undefined; - return state.taskPRs.byTaskId[taskId]?.pr_url ?? undefined; + // Single-PR consumer: surface the primary PR's URL. Multi-repo cases get + // per-repo PR URLs via the timeline's prByRepo map elsewhere. + return state.taskPRs.byTaskId[taskId]?.[0]?.pr_url ?? undefined; }); return { activeTaskId, activeSessionId, taskTitle, baseBranch, existingPrUrl }; } @@ -284,13 +107,19 @@ type ChangesPanelBodyProps = { dialogs: DialogsType; onOpenDiffFile: (path: string) => void; onEditFile: (path: string) => void; - onOpenCommitDetail?: (sha: string) => void; + onOpenCommitDetail?: (sha: string, repo?: string) => void; onOpenReview?: () => void; - onRevertCommit?: (sha: string) => void; + // Multi-repo: handlers carry the commit's repository_name so revert/amend/ + // reset target the right git repo. Without it the op runs at the workspace + // root and fails ("can only revert latest commit") on multi-repo tasks. + onRevertCommit?: (sha: string, repo?: string) => void; onStageAll: () => void; onUnstageAll: () => void; - onStage: (path: string) => Promise; - onUnstage: (path: string) => Promise; + // Multi-repo: callers pass the file's repository_name so the agentctl op + // routes to the right repo. Same-named files across repos can't be + // disambiguated by path alone. + onStage: (path: string, repo?: string) => Promise; + onUnstage: (path: string, repo?: string) => Promise; onBulkStage: (paths: string[]) => void; onBulkUnstage: (paths: string[]) => void; onBulkDiscard: (paths: string[]) => void; @@ -299,6 +128,20 @@ type ChangesPanelBodyProps = { stagedFileCount: number; stagedAdditions: number; stagedDeletions: number; + // Per-repo handlers — wired only in multi-repo workspaces. Each one targets + // a single repo subpath in agentctl. Single-repo sessions don't render the + // per-repo group header, so these are unused in that case. + onRepoStageAll?: (repo: string) => void; + onRepoUnstageAll?: (repo: string) => void; + onRepoCommit?: (repo: string) => void; + onRepoPush?: (repo: string) => void; + onRepoCreatePR?: (repo: string) => void; + /** Maps a repository_name to its display label (used as the per-repo group label). */ + repoDisplayName?: (repositoryName: string) => string | undefined; + /** Per-repo branch / ahead / behind summary; powers the "ahead" badge on Push. */ + perRepoStatus?: Array<{ repository_name: string; ahead: number }>; + /** Existing PR URL keyed by repository_name; "" key for single-repo. */ + prByRepo?: Record; }; function ChangesPanelDialogsSection({ @@ -366,6 +209,14 @@ type TimelineProps = Pick< | "onBulkDiscard" | "onPush" | "onForcePush" + | "onRepoStageAll" + | "onRepoUnstageAll" + | "onRepoCommit" + | "onRepoPush" + | "onRepoCreatePR" + | "repoDisplayName" + | "perRepoStatus" + | "prByRepo" >; type WorkingTreeProps = Pick< @@ -386,6 +237,10 @@ type WorkingTreeProps = Pick< | "onBulkStage" | "onBulkUnstage" | "onBulkDiscard" + | "onRepoStageAll" + | "onRepoUnstageAll" + | "onRepoCommit" + | "repoDisplayName" > & { isLastUnstaged: boolean; isLastStaged: boolean }; function WorkingTreeSections(props: WorkingTreeProps) { @@ -408,6 +263,8 @@ function WorkingTreeSections(props: WorkingTreeProps) { onDiscard={props.dialogs.handleDiscardClick} onBulkStage={props.onBulkStage} onBulkDiscard={props.onBulkDiscard} + onRepoAction={props.onRepoStageAll} + repoDisplayName={props.repoDisplayName} /> )} {props.hasStaged && ( @@ -418,7 +275,7 @@ function WorkingTreeSections(props: WorkingTreeProps) { isLast={props.isLastStaged} actionLabel="Commit" isActionLoading={props.loadingOperation === "commit"} - onAction={props.dialogs.openCommitDialog} + onAction={() => props.dialogs.openCommitDialog()} secondaryActionLabel="Unstage all" isSecondaryActionLoading={isBulkOp && props.loadingOperation === "unstage"} onSecondaryAction={props.onUnstageAll} @@ -429,6 +286,9 @@ function WorkingTreeSections(props: WorkingTreeProps) { onDiscard={props.dialogs.handleDiscardClick} onBulkUnstage={props.onBulkUnstage} onBulkDiscard={props.onBulkDiscard} + onRepoAction={props.onRepoCommit} + onRepoSecondaryAction={props.onRepoUnstageAll} + repoDisplayName={props.repoDisplayName} /> )} @@ -460,6 +320,7 @@ function ChangesPanelTimeline(props: TimelineProps) { files={props.prFiles} isLast={!showCommitsList} onOpenDiff={props.onOpenDiffFile} + repoDisplayName={props.repoDisplayName} /> )} @@ -477,6 +338,7 @@ function ChangesPanelTimeline(props: TimelineProps) { files={props.prFiles} isLast={!showCommitsList} onOpenDiff={props.onOpenDiffFile} + repoDisplayName={props.repoDisplayName} /> )} @@ -489,19 +351,11 @@ function ChangesPanelTimeline(props: TimelineProps) { onRevertCommit={props.onRevertCommit} onAmendCommit={props.dialogs.handleOpenAmendDialog} onResetToCommit={props.dialogs.handleOpenResetDialog} - /> - )} - {showCommits && ( - )} @@ -524,119 +378,240 @@ function ChangesPanelBody(props: ChangesPanelBodyProps) { ); } +/** + * Adapts the existing single-repo handlers to the per-repo invocation sites + * (group-header buttons). Each callback simply forwards `repo` to the + * underlying op — the routing happens inside `useSessionGit` / + * `useGitOperations`. Single-repo passes the empty string, which the backend + * treats as the workspace root. + */ +function usePerRepoCallbacks( + git: ReturnType, + vcsDialogs: ReturnType, + gitHandlers: ReturnType, +) { + // Bug 9: route per-repo stage/unstage through `handleGitOperation` so toasts + // fire for both success and failure. The previous `.catch(() => undefined)` + // silenced rejections — the user got no feedback when an op failed. + return useMemo( + () => ({ + onRepoStageAll: (repo: string) => { + gitHandlers.handleGitOperation( + () => git.stage(undefined, repo), + repo ? `Stage all (${repo})` : "Stage all", + ); + }, + onRepoUnstageAll: (repo: string) => { + gitHandlers.handleGitOperation( + () => git.unstage(undefined, repo), + repo ? `Unstage all (${repo})` : "Unstage all", + ); + }, + onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo), + onRepoPush: (repo: string) => gitHandlers.handlePush(repo), + onRepoCreatePR: (repo: string) => vcsDialogs.openPRDialog(repo), + onRepoPull: (repo: string) => gitHandlers.handlePull(repo), + onRepoRebase: (repo: string) => gitHandlers.handleRebase(repo), + onRepoMerge: (repo: string) => gitHandlers.handleMerge(repo), + }), + [git, vcsDialogs, gitHandlers], + ); +} + function useChangesPanelPRData() { + // Multi-repo tasks have one TaskPR per repo; fan out file fetches per PR + // and stamp each file with its repository_name so PRFilesSection can + // group by repo. Commits stay scoped to the primary PR for now — the + // commits section already groups by repo from the workspace git status, + // not from the PR itself. + const { prs, filesByPRKey } = useActiveTaskPRsWithFiles(); + // Subscribe to the slice (stable ref between hydrations), then build the + // id -> name lookup with useMemo so the dependency in the next useMemo is + // stable and we don't re-create the merged file list on every render. + const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const repoNameById = useMemo>(() => { + const out: Record = {}; + for (const list of Object.values(reposByWorkspace)) { + for (const r of list as Array<{ id: string; name: string }>) { + out[r.id] = r.name; + } + } + return out; + }, [reposByWorkspace]); + // Tag PR files with their repo whenever the TASK is multi-repo, not just + // when there are 2+ PRs. A multi-repo task with one PR (e.g. one repo + // doesn't have a branch yet) still benefits from the per-repo header so + // the user can tell which repo's files are being shown — the COMMITS and + // STAGED sections already do this. + const taskHasMultipleRepos = useAppStore((s) => { + const taskId = s.tasks.activeTaskId; + if (!taskId) return false; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return (task?.repositories?.length ?? 0) > 1; + }); const taskPR = useActiveTaskPR(); const refreshKey = taskPR?.last_synced_at ?? null; - const { files: prDiffFiles } = usePRDiff( - taskPR?.owner ?? null, - taskPR?.repo ?? null, - taskPR?.pr_number ?? null, - refreshKey, - ); const { commits: prCommitsList } = usePRCommits( taskPR?.owner ?? null, taskPR?.repo ?? null, taskPR?.pr_number ?? null, refreshKey, ); - const hasPRFiles = prDiffFiles.length > 0; + const { prFiles, prDiffFiles } = useMemo(() => { + const merged: PRChangedFile[] = []; + const flat: PRDiffFile[] = []; + for (const pr of prs) { + const key = `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`; + const files = filesByPRKey[key] ?? []; + const repoName = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : ""; + // Single-repo tasks render flat (the section header is enough); + // multi-repo tasks always tag with the resolved repo name so the + // PRFilesGroupedList renders one sub-header per repo, even when only + // one repo has a PR yet. Falls back to owner/repo if the workspace + // lookup misses, so the row never lands in an untagged phantom group. + const stamp = taskHasMultipleRepos ? repoName || `${pr.owner}/${pr.repo}` : ""; + merged.push(...mapPRFilesToChangedFiles(files, stamp)); + flat.push(...files); + } + return { prFiles: merged, prDiffFiles: flat }; + }, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos]); + const hasPRFiles = prFiles.length > 0; const hasPRCommits = prCommitsList.length > 0; - const prFiles = useMemo(() => mapPRFilesToChangedFiles(prDiffFiles), [prDiffFiles]); return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles }; } -const ChangesPanel = memo(function ChangesPanel({ - onOpenDiffFile, - onEditFile, - onOpenCommitDetail, - onOpenDiffAll, - onOpenReview, -}: ChangesPanelProps) { - const isArchived = useIsTaskArchived(); - const { activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData(); - +function useChangesPanelData() { + const { activeTaskId, activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData(); + const baseBranchByRepo = useBaseBranchByRepo(activeTaskId); const git = useSessionGit(activeSessionId); const { toast } = useToast(); const { reviews } = useSessionFileReviews(activeSessionId); - const { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles } = useChangesPanelPRData(); + const prData = useChangesPanelPRData(); const vcsDialogs = useVcsDialogs(); - const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]); const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]); const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]); - const { reviewedCount, totalFileCount } = useMemo( - () => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prDiffFiles), - [git.allFiles, git.cumulativeDiff, reviews, prDiffFiles], + () => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles), + [git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles], ); const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]); - const gitHandlers = useChangesGitHandlers(git, toast, baseBranch); const localDialogs = useChangesDialogHandlers(git, toast, gitHandlers.handleGitOperation); const dialogs = { ...localDialogs, ...vcsDialogs }; + const repoCallbacks = usePerRepoCallbacks(git, vcsDialogs, gitHandlers); + const repoDisplayName = useRepoDisplayName(activeSessionId); + // Existing PR URL is currently workspace-scoped — surface it under the + // empty (single-repo) key so per-repo Create PR can show "PR exists". + const prByRepo = useMemo>( + () => ({ "": existingPrUrl }), + [existingPrUrl], + ); + return { + git, + baseBranchDisplay, + baseBranchByRepo, + unstagedFiles, + stagedFiles, + reviewedCount, + totalFileCount, + staged, + gitHandlers, + localDialogs, + dialogs, + repoCallbacks, + repoDisplayName, + prByRepo, + existingPrUrl, + ...prData, + }; +} +function buildChangesPanelBodyProps( + data: ReturnType, + callbacks: ChangesPanelProps, +): ChangesPanelBodyProps { + const { git, gitHandlers, localDialogs, repoCallbacks, staged } = data; + return { + hasAnything: git.hasAnything || data.hasPRFiles || data.hasPRCommits, + hasUnstaged: git.hasUnstaged, + hasStaged: git.hasStaged, + hasCommits: git.hasCommits, + hasPRFiles: data.hasPRFiles, + hasPRCommits: data.hasPRCommits, + canPush: git.canPush, + canCreatePR: git.canCreatePR, + existingPrUrl: data.existingPrUrl, + unstagedFiles: data.unstagedFiles, + stagedFiles: data.stagedFiles, + prFiles: data.prFiles, + prCommits: data.prCommitsList, + commits: git.commits, + pendingStageFiles: git.pendingStageFiles, + reviewedCount: data.reviewedCount, + totalFileCount: data.totalFileCount, + aheadCount: git.ahead, + isLoading: git.isLoading, + loadingOperation: git.loadingOperation, + dialogs: data.dialogs, + onOpenDiffFile: callbacks.onOpenDiffFile, + onEditFile: callbacks.onEditFile, + onOpenCommitDetail: callbacks.onOpenCommitDetail, + onRevertCommit: gitHandlers.handleRevertCommit, + onOpenReview: callbacks.onOpenReview, + onStageAll: git.stageAll, + onUnstageAll: git.unstageAll, + onStage: (path, repo) => git.stageFile([path], repo).then(() => undefined), + onUnstage: (path, repo) => git.unstageFile([path], repo).then(() => undefined), + onBulkStage: (paths) => { + git.stageFile(paths).catch(() => undefined); + }, + onBulkUnstage: (paths) => { + git.unstageFile(paths).catch(() => undefined); + }, + onBulkDiscard: localDialogs.handleBulkDiscardClick, + onPush: () => gitHandlers.handlePush(), + onForcePush: () => gitHandlers.handleForcePush(), + stagedFileCount: staged.stagedFileCount, + stagedAdditions: staged.stagedAdditions, + stagedDeletions: staged.stagedDeletions, + onRepoStageAll: repoCallbacks.onRepoStageAll, + onRepoUnstageAll: repoCallbacks.onRepoUnstageAll, + onRepoCommit: repoCallbacks.onRepoCommit, + onRepoPush: repoCallbacks.onRepoPush, + onRepoCreatePR: repoCallbacks.onRepoCreatePR, + repoDisplayName: data.repoDisplayName, + perRepoStatus: git.perRepoStatus, + prByRepo: data.prByRepo, + }; +} + +const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) { + const isArchived = useIsTaskArchived(); + const data = useChangesPanelData(); if (isArchived) return ; - return ( - git.stageFile([path]).then(() => undefined)} - onUnstage={(path) => git.unstageFile([path]).then(() => undefined)} - onBulkStage={(paths) => { - git.stageFile(paths).catch(() => {}); - }} - onBulkUnstage={(paths) => { - git.unstageFile(paths).catch(() => {}); - }} - onBulkDiscard={localDialogs.handleBulkDiscardClick} - onPush={gitHandlers.handlePush} - onForcePush={gitHandlers.handleForcePush} - stagedFileCount={staged.stagedFileCount} - stagedAdditions={staged.stagedAdditions} - stagedDeletions={staged.stagedDeletions} + hasChanges={data.git.hasChanges} + hasCommits={data.git.hasCommits} + hasPRFiles={data.hasPRFiles} + displayBranch={data.git.branch} + baseBranchDisplay={data.baseBranchDisplay} + baseBranchByRepo={data.baseBranchByRepo} + behindCount={data.git.behind} + isLoading={data.git.isLoading} + loadingOperation={data.git.loadingOperation} + onOpenDiffAll={props.onOpenDiffAll} + onOpenReview={props.onOpenReview} + repoNames={data.git.repoNames} + perRepoStatus={data.git.perRepoStatus} + onRepoPull={data.repoCallbacks.onRepoPull} + onRepoRebase={data.repoCallbacks.onRepoRebase} + onRepoMerge={data.repoCallbacks.onRepoMerge} + repoDisplayName={data.repoDisplayName} /> + ); }); diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index ceb35de04f..9e9b82153a 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -209,7 +209,7 @@ export function useChatPanelHandlers( } function PRMergedBanner({ taskId }: { taskId: string }) { - const taskPR = useAppStore((state) => state.taskPRs.byTaskId[taskId]); + const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); const [dismissed, setDismissed] = useState(false); const archiveAndSwitch = useArchiveAndSwitchTask(); const { toast } = useToast(); @@ -223,7 +223,15 @@ function PRMergedBanner({ taskId }: { taskId: string }) { } }, [taskId, archiveAndSwitch, toast]); - if (taskPR?.state !== "merged" || dismissed) return null; + // Multi-repo: only show "ready to archive" once every PR is merged. A + // single merged repo with others still open means the task isn't done yet. + const allMerged = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "merged"); + if (!allMerged || dismissed) return null; + + const bannerText = + taskPRs.length === 1 + ? `PR #${taskPRs[0].pr_number} has been merged. You can archive this task.` + : `All ${taskPRs.length} PRs have been merged. You can archive this task.`; return (
    - - PR #{taskPR.pr_number} has been merged. You can archive this task. - + {bannerText} + + + + + + Pick a repository for {primaryButtonConfig.label} + + ); +} diff --git a/apps/web/components/vcs-split-button.tsx b/apps/web/components/vcs-split-button.tsx index f3d52459c1..76169a9b32 100644 --- a/apps/web/components/vcs-split-button.tsx +++ b/apps/web/components/vcs-split-button.tsx @@ -29,6 +29,10 @@ import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; +import { MultiRepoVcsButton } from "@/components/vcs-multi-repo-menu"; + +const DEFAULT_BASE_BRANCH = "origin/main"; function determinePrimaryAction( uncommittedFileCount: number, @@ -95,8 +99,8 @@ function buildRebaseConfig( return { icon: , label: "Rebase", - badge: null, - tooltip: `Rebase onto ${baseBranch || "origin/main"} (${behindCount} behind)`, + badge: behindCount > 0 ? behindCount : null, + tooltip: `Rebase onto ${baseBranch || DEFAULT_BASE_BRANCH} (${behindCount} behind)`, onClick: handleRebase, }; } @@ -247,12 +251,16 @@ function VcsDropdownItems({ Rebase - onto {baseBranch || "origin/main"} + + onto {baseBranch || DEFAULT_BASE_BRANCH} + Merge - from {baseBranch || "origin/main"} + + from {baseBranch || DEFAULT_BASE_BRANCH} + ); @@ -268,26 +276,40 @@ type VcsSplitButtonProps = { function useGitActions(git: ReturnType, baseBranch?: string) { const gitWithFeedback = useGitWithFeedback(); - const handlePull = useCallback(() => { - gitWithFeedback(() => git.pull(), "Pull"); - }, [gitWithFeedback, git]); - - const handlePush = useCallback( - (force = false) => { - gitWithFeedback(() => git.push({ force }), force ? "Force Push" : "Push"); + const handlePull = useCallback( + (repo?: string) => { + const label = repo ? `Pull (${repo})` : "Pull"; + gitWithFeedback(() => git.pull(false, repo), label); }, [gitWithFeedback, git], ); - const handleRebase = useCallback(() => { - const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; - gitWithFeedback(() => git.rebase(targetBranch), "Rebase"); - }, [gitWithFeedback, git, baseBranch]); + const handlePush = useCallback( + (force = false, repo?: string) => { + const baseLabel = force ? "Force Push" : "Push"; + const label = repo ? `${baseLabel} (${repo})` : baseLabel; + gitWithFeedback(() => git.push({ force }, repo), label); + }, + [gitWithFeedback, git], + ); - const handleMerge = useCallback(() => { - const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; - gitWithFeedback(() => git.merge(targetBranch), "Merge"); - }, [gitWithFeedback, git, baseBranch]); + const handleRebase = useCallback( + (repo?: string) => { + const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; + const label = repo ? `Rebase (${repo})` : "Rebase"; + gitWithFeedback(() => git.rebase(targetBranch, repo), label); + }, + [gitWithFeedback, git, baseBranch], + ); + + const handleMerge = useCallback( + (repo?: string) => { + const targetBranch = baseBranch?.replace(/^origin\//, "") || "main"; + const label = repo ? `Merge (${repo})` : "Merge"; + gitWithFeedback(() => git.merge(targetBranch, repo), label); + }, + [gitWithFeedback, git, baseBranch], + ); return { handlePull, handlePush, handleRebase, handleMerge }; } @@ -303,6 +325,7 @@ const VcsSplitButton = memo(function VcsSplitButton({ const activePR = useActiveTaskPR(); const hasOpenPR = activePR?.state === "open"; const { handlePull, handlePush, handleRebase, handleMerge } = useGitActions(git, baseBranch); + const repoDisplayName = useRepoDisplayName(sessionId); const currentBranch = git.branch; const remoteBranch = git.remoteBranch; @@ -313,6 +336,9 @@ const VcsSplitButton = memo(function VcsSplitButton({ const behindCount = git.behind; const isDisabled = git.isLoading || !sessionId; const isGitLoading = git.isLoading; + // Multi-repo when there's more than one named repo. Single-repo workspaces + // get either a single empty-name entry or no entries at all in repoNames. + const isMultiRepo = git.repoNames.filter((r) => r !== "").length > 1; const primaryAction = determinePrimaryAction( uncommittedFileCount, @@ -333,6 +359,86 @@ const VcsSplitButton = memo(function VcsSplitButton({ }); const showDivergencePills = primaryAction !== "commit"; + if (isMultiRepo) { + return ( + openCommitDialog(repo), + onPR: (repo) => openPRDialog(repo), + onPull: handlePull, + onPush: handlePush, + onRebase: handleRebase, + onMerge: handleMerge, + }} + /> + ); + } + + return ( + openPRDialog()} + onPull={() => handlePull()} + onPush={(force) => handlePush(force)} + onRebase={() => handleRebase()} + onMerge={() => handleMerge()} + /> + ); +}); + +function SingleRepoVcsButton({ + primaryButtonConfig, + primaryAction, + isDisabled, + isGitLoading, + baseBranch, + hasMatchingUpstream, + behindCount, + aheadCount, + onPR, + onPull, + onPush, + onRebase, + onMerge, + className, + buttonSize = "sm", + showDivergencePills = false, +}: { + primaryButtonConfig: PrimaryButtonConfig; + primaryAction: "commit" | "push" | "pr" | "rebase"; + isDisabled: boolean; + isGitLoading: boolean; + baseBranch?: string; + hasMatchingUpstream: boolean | "" | null | undefined; + behindCount: number; + aheadCount: number; + onPR: () => void; + onPull: () => void; + onPush: (force: boolean) => void; + onRebase: () => void; + onMerge: () => void; + className?: string; + buttonSize?: ComponentProps["size"]; + showDivergencePills?: boolean; +}) { return (
    @@ -379,15 +485,15 @@ const VcsSplitButton = memo(function VcsSplitButton({ hasMatchingUpstream={hasMatchingUpstream} behindCount={behindCount} aheadCount={aheadCount} - onPR={openPRDialog} - onPull={handlePull} - onPush={handlePush} - onRebase={handleRebase} - onMerge={handleMerge} + onPR={onPR} + onPull={onPull} + onPush={onPush} + onRebase={onRebase} + onMerge={onMerge} />
    ); -}); +} export { VcsSplitButton }; diff --git a/apps/web/components/vcs/vcs-dialogs.tsx b/apps/web/components/vcs/vcs-dialogs.tsx index 269ac271a5..df12ca729a 100644 --- a/apps/web/components/vcs/vcs-dialogs.tsx +++ b/apps/web/components/vcs/vcs-dialogs.tsx @@ -21,7 +21,12 @@ import { PRDescriptionField, PRBranchSummary, } from "./vcs-dialog-fields"; -import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; +import { + useSessionGitStatus, + useSessionGitStatusByRepo, +} from "@/hooks/domains/session/use-session-git-status"; +import { useSessionGit } from "@/hooks/domains/session/use-session-git"; +import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; import { useGitOperations } from "@/hooks/use-git-operations"; import { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; @@ -30,8 +35,10 @@ import { useToast } from "@/components/toast-provider"; import type { FileInfo } from "@/lib/state/slices"; type VcsDialogsContextValue = { - openCommitDialog: () => void; - openPRDialog: () => void; + /** When `repo` is provided, the commit is scoped to that repo only. */ + openCommitDialog: (repo?: string) => void; + /** When `repo` is provided, the PR is scoped to that repo only. */ + openPRDialog: (repo?: string) => void; }; const VcsDialogsContext = createContext(null); @@ -52,17 +59,27 @@ type VcsDialogsProviderProps = { type FileSummary = { count: number; additions: number; deletions: number }; -function computeFileSummary(files: Record | undefined): FileSummary { - const count = files ? Object.keys(files).length : 0; +/** + * Counts files for the commit dialog summary. + * - When `stageAll=true` (the default), include every file (staged + unstaged) + * because the commit op stages them all before committing. + * - When `stageAll=false`, count only staged files — those are the only files + * the commit will actually include. Counting all here would over-state what + * the commit produces and surprise the user post-commit. + */ +function computeFileSummary( + files: Record | undefined, + stageAll: boolean = true, +): FileSummary { + if (!files) return { count: 0, additions: 0, deletions: 0 }; + const considered = (Object.values(files) as FileInfo[]).filter((f) => stageAll || f.staged); let additions = 0; let deletions = 0; - if (files && count > 0) { - for (const file of Object.values(files) as FileInfo[]) { - additions += file.additions || 0; - deletions += file.deletions || 0; - } + for (const file of considered) { + additions += file.additions || 0; + deletions += file.deletions || 0; } - return { count, additions, deletions }; + return { count: considered.length, additions, deletions }; } function FileSummaryText({ count, additions, deletions }: FileSummary) { @@ -85,6 +102,8 @@ function FileSummaryText({ count, additions, deletions }: FileSummary) { type CommitDialogProps = { open: boolean; onOpenChange: (v: boolean) => void; + /** When set, the dialog title shows the repo name and the summary is repo-scoped. */ + scopedRepo?: string; fileSummary: FileSummary; commitMessage: string; onCommitMessageChange: (v: string) => void; @@ -104,6 +123,7 @@ type CommitDialogProps = { function CommitDialog({ open, onOpenChange, + scopedRepo, fileSummary, commitMessage, onCommitMessageChange, @@ -125,7 +145,7 @@ function CommitDialog({ - Commit Changes + {scopedRepo ? `Commit Changes — ${scopedRepo}` : "Commit Changes"}
    @@ -198,6 +218,8 @@ function CommitDialog({ type PRDialogProps = { open: boolean; onOpenChange: (v: boolean) => void; + /** When set, the dialog title shows the repo name. */ + scopedRepo?: string; displayBranch?: string | null; baseBranch?: string; prTitle: string; @@ -218,6 +240,7 @@ type PRDialogProps = { function PRDialog({ open, onOpenChange, + scopedRepo, displayBranch, baseBranch, prTitle, @@ -240,7 +263,7 @@ function PRDialog({ - Create Pull Request + {scopedRepo ? `Create Pull Request — ${scopedRepo}` : "Create Pull Request"}
    @@ -304,7 +327,10 @@ type UseCommitDialogReturn = { setBody: (v: string) => void; stageAll: boolean; setStageAll: (v: boolean) => void; - openDialog: () => void; + /** Repo this commit is scoped to in multi-repo mode; "" = all repos with staged. */ + repo: string; + setRepo: (v: string) => void; + openDialog: (repo?: string) => void; }; function useCommitDialogState(): UseCommitDialogReturn { @@ -312,13 +338,29 @@ function useCommitDialogState(): UseCommitDialogReturn { const [message, setMessage] = useState(""); const [body, setBody] = useState(""); const [stageAll, setStageAll] = useState(true); - const openDialog = useCallback(() => { + const [repo, setRepo] = useState(""); + const openDialog = useCallback((nextRepo?: string) => { setMessage(""); setBody(""); setStageAll(true); + // Defensive: callers binding `openDialog` directly to onClick can leak the + // React MouseEvent into nextRepo. Only accept actual repo strings. + setRepo(typeof nextRepo === "string" ? nextRepo : ""); setOpen(true); }, []); - return { open, setOpen, message, setMessage, body, setBody, stageAll, setStageAll, openDialog }; + return { + open, + setOpen, + message, + setMessage, + body, + setBody, + stageAll, + setStageAll, + repo, + setRepo, + openDialog, + }; } type UsePRDialogReturn = { @@ -330,7 +372,10 @@ type UsePRDialogReturn = { setBody: (v: string) => void; draft: boolean; setDraft: (v: boolean) => void; - openDialog: (taskTitle?: string) => void; + /** Repo this PR is scoped to in multi-repo mode; "" = workspace root. */ + repo: string; + setRepo: (v: string) => void; + openDialog: (taskTitle?: string, repo?: string) => void; }; function usePRDialogState(): UsePRDialogReturn { @@ -338,12 +383,84 @@ function usePRDialogState(): UsePRDialogReturn { const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [draft, setDraft] = useState(true); - const openDialog = useCallback((taskTitle?: string) => { + const [repo, setRepo] = useState(""); + const openDialog = useCallback((taskTitle?: string, nextRepo?: string) => { setTitle(taskTitle || ""); setBody(""); + // Defensive: callers binding `openDialog` directly to onClick can leak the + // React MouseEvent into nextRepo. Only accept actual repo strings. + setRepo(typeof nextRepo === "string" ? nextRepo : ""); setOpen(true); }, []); - return { open, setOpen, title, setTitle, body, setBody, draft, setDraft, openDialog }; + return { + open, + setOpen, + title, + setTitle, + body, + setBody, + draft, + setDraft, + repo, + setRepo, + openDialog, + }; +} + +/** + * Computes the file summary for the commit dialog: an explicit repo scope + * uses that repo's files; an empty scope in multi-repo sums across every + * repo (showing the fan-out total); single-repo falls back to the legacy + * workspace-level status. + */ +function useScopedFileSummary({ + scopedRepo, + statusByRepo, + gitStatus, + isMultiRepo, + stageAll, +}: { + scopedRepo: string; + statusByRepo: ReturnType; + gitStatus: ReturnType; + isMultiRepo: boolean; + /** Mirrors the dialog's "Stage all changes before committing" checkbox. */ + stageAll: boolean; +}): FileSummary { + return useMemo(() => { + if (scopedRepo) { + const scoped = statusByRepo.find((s) => s.repository_name === scopedRepo); + return computeFileSummary(scoped?.status?.files, stageAll); + } + if (isMultiRepo) { + let count = 0; + let additions = 0; + let deletions = 0; + for (const { status } of statusByRepo) { + const s = computeFileSummary(status?.files, stageAll); + count += s.count; + additions += s.additions; + deletions += s.deletions; + } + return { count, additions, deletions }; + } + return computeFileSummary(gitStatus?.files, stageAll); + }, [scopedRepo, statusByRepo, gitStatus, isMultiRepo, stageAll]); +} + +/** + * Resolves the label shown in dialog titles. Explicit repo wins; otherwise + * empty scope resolves to the primary single-repo display name, or "All + * repos" when the workspace has multiple repos and the dialog is fanning out. + */ +function pickRepoLabel( + scopedRepo: string, + isMultiRepo: boolean, + resolveDisplayName: (name: string) => string | undefined, +): string { + if (scopedRepo) return resolveDisplayName(scopedRepo) || scopedRepo; + if (isMultiRepo) return "All repos"; + return resolveDisplayName("") || "Repository"; } function useCreatePRHandler( @@ -356,7 +473,13 @@ function useCreatePRHandler( if (!ps.title.trim()) return; ps.setOpen(false); try { - const result = await createPR(ps.title.trim(), ps.body.trim(), baseBranch, ps.draft); + const result = await createPR( + ps.title.trim(), + ps.body.trim(), + baseBranch, + ps.draft, + ps.repo || undefined, + ); if (result.success) { const title = ps.draft ? "Draft PR created" : "PR created"; toast({ @@ -384,6 +507,66 @@ function useCreatePRHandler( }, [ps, baseBranch, createPR, toast]); } +function useVcsDialogsState( + sessionId: string | null, + taskTitle: string | undefined, + baseBranch: string | undefined, +) { + const cs = useCommitDialogState(); + const ps = usePRDialogState(); + const { toast } = useToast(); + const gitWithFeedback = useGitWithFeedback(); + const gitStatus = useSessionGitStatus(sessionId); + const statusByRepo = useSessionGitStatusByRepo(sessionId); + // Use SessionGit so commit fans out per-repo for multi-repo workspaces. + // useGitOperations.commit hits the workspace root, which fails for multi-repo + // tasks because the task root isn't itself a git repo (exit 1). + const { commit, createPR, repoNames, isLoading: isGitLoading } = useSessionGit(sessionId); + const repoDisplayName = useRepoDisplayName(sessionId); + const isMultiRepo = repoNames.filter((r) => r !== "").length > 1; + const fileSummary = useScopedFileSummary({ + scopedRepo: cs.repo, + statusByRepo, + gitStatus, + isMultiRepo, + stageAll: cs.stageAll, + }); + const handleCommit = useCallback(async () => { + if (!cs.message.trim()) return; + cs.setOpen(false); + const title = cs.message.trim(); + const body = cs.body.trim(); + const fullMessage = body ? `${title}\n\n${body}` : title; + const label = cs.repo ? `Commit (${cs.repo})` : "Commit"; + await gitWithFeedback( + () => commit(fullMessage, cs.stageAll, false, cs.repo || undefined), + label, + ); + cs.setMessage(""); + cs.setBody(""); + cs.setRepo(""); + }, [cs, gitWithFeedback, commit]); + const handleCreatePR = useCreatePRHandler(ps, baseBranch, createPR, toast); + const contextValue = useMemo( + () => ({ + openCommitDialog: cs.openDialog, + openPRDialog: (repo?: string) => ps.openDialog(taskTitle, repo), + }), + [cs.openDialog, ps, taskTitle], + ); + return { + cs, + ps, + isGitLoading, + fileSummary, + handleCommit, + handleCreatePR, + contextValue, + repoDisplayName, + isMultiRepo, + }; +} + export function VcsDialogsProvider({ sessionId, baseBranch, @@ -391,12 +574,10 @@ export function VcsDialogsProvider({ displayBranch, children, }: VcsDialogsProviderProps) { - const cs = useCommitDialogState(); - const ps = usePRDialogState(); - const { toast } = useToast(); - const gitWithFeedback = useGitWithFeedback(); - const gitStatus = useSessionGitStatus(sessionId); - const { commit, createPR, isLoading: isGitLoading } = useGitOperations(sessionId); + const state = useVcsDialogsState(sessionId, taskTitle, baseBranch); + const { cs, ps, isGitLoading, fileSummary, handleCommit, handleCreatePR, contextValue } = state; + const effectiveRepoLabel = pickRepoLabel(cs.repo, state.isMultiRepo, state.repoDisplayName); + const effectivePRLabel = pickRepoLabel(ps.repo, state.isMultiRepo, state.repoDisplayName); const isUtilityConfigured = useIsUtilityConfigured(); const { isGeneratingCommitMessage, @@ -408,28 +589,6 @@ export function VcsDialogsProvider({ generatePRTitle, generatePRDescription, } = useUtilityAgentGenerator({ sessionId, taskTitle }); - const fileSummary = computeFileSummary(gitStatus?.files); - - const handleCommit = useCallback(async () => { - if (!cs.message.trim()) return; - cs.setOpen(false); - const title = cs.message.trim(); - const body = cs.body.trim(); - const fullMessage = body ? `${title}\n\n${body}` : title; - await gitWithFeedback(() => commit(fullMessage, cs.stageAll), "Commit"); - cs.setMessage(""); - cs.setBody(""); - }, [cs, gitWithFeedback, commit]); - - const handleCreatePR = useCreatePRHandler(ps, baseBranch, createPR, toast); - - const contextValue = useMemo( - () => ({ - openCommitDialog: cs.openDialog, - openPRDialog: () => ps.openDialog(taskTitle), - }), - [cs.openDialog, ps, taskTitle], - ); return ( @@ -437,6 +596,7 @@ export function VcsDialogsProvider({ { // Verify both commits appear const commitsList = testPage.getByTestId("commits-list"); - await expect(commitsList.locator("li")).toHaveCount(2, { timeout: 5_000 }); + await expect(commitsList.locator('[data-testid^="commit-row-"]')).toHaveCount(2, { + timeout: 5_000, + }); // Find the first commit row (it's the second in the list, older commit) // The list shows newest first, so "Second commit" is at index 0, "First commit" at index 1 - const firstCommitRow = commitsList.locator("li").filter({ hasText: "First commit" }); + const firstCommitRow = commitsList + .locator('[data-testid^="commit-row-"]') + .filter({ hasText: "First commit" }); await expect(firstCommitRow).toBeVisible({ timeout: 5_000 }); // Get the SHA from the commit row to use in the confirmation @@ -1275,7 +1279,9 @@ test.describe("Git Changes Panel", () => { await expect(testPage.getByTestId("commits-section")).toBeVisible({ timeout: 15_000 }); await expect(session.changes.getByText("Commit in PR")).toBeVisible({ timeout: 10_000 }); const commitsList = testPage.getByTestId("commits-list"); - await expect(commitsList.locator("li")).toHaveCount(1, { timeout: 5_000 }); + await expect(commitsList.locator('[data-testid^="commit-row-"]')).toHaveCount(1, { + timeout: 5_000, + }); // Pushed commits use IconGitCommit (tabler-icon-git-commit), not IconArrowUp await expect(commitsList.locator(".tabler-icon-git-commit")).toBeVisible({ timeout: 5_000 }); await expect(commitsList.locator(".tabler-icon-arrow-up")).not.toBeVisible(); @@ -1359,16 +1365,23 @@ test.describe("Git Changes Panel", () => { // Other tests in the same worker may have left additional commits in the shared e2e-repo. await expect(testPage.getByTestId("commits-section")).toBeVisible({ timeout: 15_000 }); const commitsList = testPage.getByTestId("commits-list"); - await expect(commitsList.locator("li").first()).toBeVisible({ timeout: 5_000 }); + await expect(commitsList.locator('[data-testid^="commit-row-"]').first()).toBeVisible({ + timeout: 5_000, + }); - // Unpushed commit should have arrow-up icon (emerald) - const unpushedRow = commitsList.locator("li", { hasText: "Unpushed commit" }); + // Unpushed commit should have arrow-up icon (emerald). Scope to the + // commit-row testid so we don't match the parent repo-group
  • too + // (which also contains the same text since it wraps the commits). + const unpushedRow = commitsList + .locator('[data-testid^="commit-row-"]', { hasText: "Unpushed commit" }) + .first(); await expect(unpushedRow).toBeVisible({ timeout: 10_000 }); await expect(unpushedRow.locator(".tabler-icon-arrow-up")).toBeVisible({ timeout: 5_000 }); // Pushed commit should have git-commit icon (muted) const pushedRow = commitsList - .locator(`li:has-text("Pushed commit"):not(:has-text("Unpushed"))`) + .locator('[data-testid^="commit-row-"]', { hasText: "Pushed commit" }) + .filter({ hasNotText: "Unpushed" }) .first(); await expect(pushedRow).toBeVisible({ timeout: 10_000 }); await expect(pushedRow.locator(".tabler-icon-git-commit")).toBeVisible({ timeout: 5_000 }); diff --git a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts index 97a1650de0..da72262b1c 100644 --- a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts +++ b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts @@ -43,7 +43,7 @@ test.describe("Branch selector behavior with executor types", () => { await testPage.getByRole("option", { name: /E2E Local Profile/i }).click(); // Branch selector should be disabled when local executor is selected - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeDisabled({ timeout: 5_000 }); } finally { await apiClient.deleteExecutorProfile(profile.id).catch(() => {}); @@ -85,7 +85,7 @@ test.describe("Branch selector behavior with executor types", () => { await executorSelector.click(); await testPage.getByRole("option", { name: /^E2E Local Local$/i }).click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeDisabled({ timeout: 5_000 }); // Switch to worktree executor -> branch should be enabled @@ -162,7 +162,7 @@ test.describe("Branch selector behavior with executor types", () => { await testPage.getByRole("option", { name: /E2E Local GitHub URL/i }).click(); // Branch selector should NOT be disabled (GitHub URL mode overrides) - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 10_000 }); } finally { await apiClient.deleteExecutorProfile(profile.id).catch(() => {}); @@ -228,7 +228,7 @@ test.describe("Fresh-branch flow", () => { await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Fresh Branch Test"); await testPage.getByTestId("task-description-input").fill("testing fresh branch"); - await testPage.getByTestId("repository-selector").click(); + await testPage.getByTestId("repo-chip-trigger").first().click(); await testPage .getByRole("option", { name: new RegExp(`^${escapeRe(repoName)}\\b`, "i") }) .first() @@ -256,7 +256,7 @@ test.describe("Fresh-branch flow", () => { const toggle = testPage.getByTestId("fresh-branch-toggle"); await expect(toggle).toBeVisible(); await expect(toggle).toHaveAttribute("aria-pressed", "false"); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeDisabled({ timeout: 5_000 }); // Placeholder should show actual current branch (main), not the generic copy. await expect(branchSelector).toContainText("main", { timeout: 5_000 }); @@ -279,7 +279,7 @@ test.describe("Fresh-branch flow", () => { try { await openDialogWithLocalProfile(testPage, setup.profileName, setup.repoName); await testPage.getByTestId("fresh-branch-toggle").click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); // Pick the develop base branch so the new branch will fork from it. await branchSelector.click(); @@ -481,7 +481,7 @@ test.describe("Fresh-branch flow", () => { await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Switcheroo"); await testPage.getByTestId("task-description-input").fill("repro the leftover-branch bug"); - await testPage.getByTestId("repository-selector").click(); + await testPage.getByTestId("repo-chip-trigger").first().click(); await testPage .getByRole("option", { name: new RegExp(`^${setup.repoName}\\b`, "i") }) .first() @@ -495,7 +495,7 @@ test.describe("Fresh-branch flow", () => { .filter({ hasText: /worktree/i }) .first() .click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); await branchSelector.click(); await testPage @@ -563,7 +563,7 @@ test.describe("Branch refresh + filter", () => { await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Refresh button test"); await testPage.getByTestId("task-description-input").fill("triggers git fetch"); - await testPage.getByTestId("repository-selector").click(); + await testPage.getByTestId("repo-chip-trigger").first().click(); await testPage .getByRole("option", { name: new RegExp(`^${escapeRe(seededRepoName)}\\b`, "i") }) .first() @@ -572,7 +572,7 @@ test.describe("Branch refresh + filter", () => { await testPage.getByTestId("executor-profile-selector").click(); await testPage.getByRole("option", { name: worktreeProfileName }).click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); await branchSelector.click(); @@ -634,7 +634,7 @@ test.describe("Branch refresh + filter", () => { await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); await testPage.getByTestId("task-title-input").fill("Filter ranking test"); await testPage.getByTestId("task-description-input").fill("exact match wins"); - await testPage.getByTestId("repository-selector").click(); + await testPage.getByTestId("repo-chip-trigger").first().click(); await testPage .getByRole("option", { name: new RegExp(`^${repoName}\\b`, "i") }) .first() @@ -642,7 +642,7 @@ test.describe("Branch refresh + filter", () => { await testPage.getByTestId("executor-profile-selector").click(); await testPage.getByRole("option", { name: worktreeProfileName }).click(); - const branchSelector = testPage.getByTestId("branch-selector"); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); await branchSelector.click(); diff --git a/apps/web/e2e/tests/task/create-task-github-url.spec.ts b/apps/web/e2e/tests/task/create-task-github-url.spec.ts index aadd2dda9f..0fdf2c7dc1 100644 --- a/apps/web/e2e/tests/task/create-task-github-url.spec.ts +++ b/apps/web/e2e/tests/task/create-task-github-url.spec.ts @@ -904,7 +904,7 @@ test.describe("Task creation from GitHub URL", () => { // Toggle to GitHub URL mode await toggleBtn.click(); await expect(testPage.getByTestId("github-url-input")).toBeVisible(); - await expect(toggleBtn).toHaveText("or select a repository"); + await expect(toggleBtn).toHaveText("use a workspace repository"); // Toggle back to repository selector await toggleBtn.click(); diff --git a/apps/web/e2e/tests/task/create-task.spec.ts b/apps/web/e2e/tests/task/create-task.spec.ts index 134500c479..4e651e2c17 100644 --- a/apps/web/e2e/tests/task/create-task.spec.ts +++ b/apps/web/e2e/tests/task/create-task.spec.ts @@ -25,9 +25,10 @@ test.describe("Task creation", () => { const startBtn = testPage.getByTestId(START_AGENT_TEST_ID); await expect(startBtn).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); - // Verify the pre-seeded selections are displayed in the dialog selectors - await expect(testPage.getByTestId("repository-selector")).toContainText("E2E Repo"); - await expect(testPage.getByTestId("branch-selector")).toContainText("main"); + // Verify the pre-seeded selections are displayed in the dialog selectors. + // The dialog uses chip-style repo/branch pills since the multi-repo refactor. + await expect(testPage.getByTestId("repo-chip-trigger").first()).toContainText("E2E Repo"); + await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main"); // Agent profile name varies — just verify it's not the empty placeholder await expect(testPage.getByTestId("agent-profile-selector")).not.toContainText( "Select agent...", @@ -62,8 +63,8 @@ test.describe("Task creation", () => { await expect(startBtn2).toBeEnabled({ timeout: START_ENABLED_TIMEOUT }); // The same repo, branch, and agent profile should still be selected - await expect(testPage.getByTestId("repository-selector")).toContainText("E2E Repo"); - await expect(testPage.getByTestId("branch-selector")).toContainText("main"); + await expect(testPage.getByTestId("repo-chip-trigger").first()).toContainText("E2E Repo"); + await expect(testPage.getByTestId("branch-chip-trigger").first()).toContainText("main"); await expect(testPage.getByTestId("agent-profile-selector")).not.toContainText( "Select agent...", ); diff --git a/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts b/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts index 9046da59e6..0edce8beb5 100644 --- a/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts +++ b/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts @@ -44,8 +44,10 @@ test.describe("repository selector scroll inside dialog", () => { const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); - // Open the repository selector popover. - await testPage.getByTestId("repository-selector").click(); + // Open the repository selector popover via the chip-row trigger + // (the dialog now uses the multi-repo chip layout, not the legacy + // single repository-selector combobox). + await testPage.getByTestId("repo-chip-trigger").first().click(); // The popover's cmdk list must live inside the dialog DOM (not portaled // to body), otherwise react-remove-scroll eats wheel events. diff --git a/apps/web/hooks/domains/github/use-active-task-pr-files.ts b/apps/web/hooks/domains/github/use-active-task-pr-files.ts new file mode 100644 index 0000000000..0d66d89fca --- /dev/null +++ b/apps/web/hooks/domains/github/use-active-task-pr-files.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAppStore } from "@/components/state-provider"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import type { PRDiffFile, TaskPR } from "@/lib/types/github"; + +type PRFilesByKey = Record; + +// Stable empty array so the Zustand selector returns the same reference +// for tasks with zero PRs. A fresh `[]` per render would re-trigger the +// selector subscriber and cascade through every effect that depends on +// `prs`. +const EMPTY_PRS: TaskPR[] = []; + +/** + * Cache key for an in-flight fetch — owner/repo/PR + the last_synced_at hint + * from the TaskPR row, so a server-side sync invalidates the cache and + * triggers a refetch automatically. + */ +function fetchKey(pr: TaskPR): string { + return `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`; +} + +/** + * Returns one diff array per task PR, keyed by `${owner}/${repo}/${prNumber}/${last_synced_at}`. + * Internally fans out one WS request per PR and tracks them in local state — + * we can't use `usePRDiff` directly because hooks can't be called in a loop. + * + * Designed for the changes panel's PR Changes section, which needs to render + * one row per file across every per-repo PR (multi-repo tasks now have one + * PR per repo, not just one for the whole task). + */ +export function useActiveTaskPRsWithFiles(): { + prs: TaskPR[]; + filesByPRKey: PRFilesByKey; +} { + const prs = useAppStore((s) => { + const taskId = s.tasks.activeTaskId; + if (!taskId) return EMPTY_PRS; + return s.taskPRs.byTaskId[taskId] ?? EMPTY_PRS; + }); + + const [filesByPRKey, setFilesByPRKey] = useState({}); + // Refs so we can synchronously skip duplicate fetches without extra + // state updates (the lint rule rightly objects to setState-in-effect). + // Reset whenever the desired key set changes — a new last_synced_at + // counts as a brand-new fetch. + const inFlightRef = useRef>(new Set()); + const fetchedRef = useRef>(new Set()); + + // The set of keys we *want* to have results for. Drives the diff between + // current state and what needs fetching, and lets us GC stale entries + // (e.g. when a PR is deleted upstream or last_synced_at advances). + const desiredKeys = useMemo(() => prs.map(fetchKey), [prs]); + + // Drop cached results / tracking refs whose key is no longer desired. + // Without this, switching tasks would leak stale PR file lists forever. + // The setState is the GC step for an external (Zustand) state change — + // pruneByKeySet returns the same reference when nothing changed, so this + // does not cause cascading renders. + useEffect(() => { + const desiredSet = new Set(desiredKeys); + for (const k of inFlightRef.current) { + if (!desiredSet.has(k)) inFlightRef.current.delete(k); + } + for (const k of fetchedRef.current) { + if (!desiredSet.has(k)) fetchedRef.current.delete(k); + } + // eslint-disable-next-line react-hooks/set-state-in-effect -- GC for external store change; no-op when nothing was pruned. + setFilesByPRKey((prev) => pruneByKeySet(prev, desiredSet)); + }, [desiredKeys]); + + // Issue one fetch per PR that hasn't been fetched yet under its current key. + useEffect(() => { + const client = getWebSocketClient(); + if (!client) return; + for (const pr of prs) { + const key = fetchKey(pr); + if (fetchedRef.current.has(key) || inFlightRef.current.has(key)) continue; + inFlightRef.current.add(key); + void client + .request<{ files?: PRDiffFile[] }>("github.pr_files.get", { + owner: pr.owner, + repo: pr.repo, + number: pr.pr_number, + }) + .then((response) => { + inFlightRef.current.delete(key); + fetchedRef.current.add(key); + setFilesByPRKey((prev) => ({ ...prev, [key]: response?.files ?? [] })); + }) + .catch(() => { + inFlightRef.current.delete(key); + fetchedRef.current.add(key); + setFilesByPRKey((prev) => ({ ...prev, [key]: [] })); + }); + } + // No cleanup-time cancellation: the per-key dedup via inFlightRef + + // fetchedRef already prevents duplicate requests, and the response + // handlers use functional setState so they're safe to land after the + // effect re-runs. Adding `cancelled = true` here used to drop responses + // from the previous effect instance — and since the next effect's + // early-continue saw the key still in inFlightRef, no fresh request + // was issued either, leaving files permanently empty. + }, [prs]); + + return { prs, filesByPRKey }; +} + +function pruneByKeySet(prev: Record, desiredSet: Set): Record { + let changed = false; + const next: Record = {}; + for (const k of Object.keys(prev)) { + if (desiredSet.has(k)) { + next[k] = prev[k]; + } else { + changed = true; + } + } + return changed ? next : prev; +} + +export { fetchKey as prFetchKey }; diff --git a/apps/web/hooks/domains/github/use-task-pr.ts b/apps/web/hooks/domains/github/use-task-pr.ts index 8971e1303c..e006d4a55a 100644 --- a/apps/web/hooks/domains/github/use-task-pr.ts +++ b/apps/web/hooks/domains/github/use-task-pr.ts @@ -45,9 +45,33 @@ export function useWorkspacePRs(workspaceId: string | null) { const SYNC_RETRY_DELAY = 5_000; // 5 seconds const SYNC_MAX_RETRIES = 6; // Up to 30 seconds of retries -/** Fetch a single task's PR association, with on-demand sync via WS. */ +/** + * Returns the primary PR (first by created_at) for a task. Multi-repo tasks + * may have additional PRs — use `useTaskPRs` to get the full list. + */ +export function getPrimaryTaskPR(prs: TaskPR[] | undefined): TaskPR | null { + return prs && prs.length > 0 ? prs[0] : null; +} + +/** + * Normalises the WS sync response into an array of TaskPR rows. Backend + * returns `{prs: TaskPR[]}` (current shape) for multi-repo support, but we + * accept the legacy bare-TaskPR shape too in case an older backend is + * still running. Empty / null / unknown shapes return an empty array. + */ +function normalizeSyncResponse(result: { prs?: TaskPR[] } | TaskPR | null | undefined): TaskPR[] { + if (!result) return []; + const envelope = result as { prs?: TaskPR[] }; + if (Array.isArray(envelope.prs)) return envelope.prs; + const single = result as TaskPR; + if (single.task_id) return [single]; + return []; +} + +/** Fetch a single task's PR associations, with on-demand sync via WS. */ export function useTaskPR(taskId: string | null) { - const pr = useAppStore((state) => (taskId ? (state.taskPRs.byTaskId[taskId] ?? null) : null)); + const prs = useAppStore((state) => (taskId ? (state.taskPRs.byTaskId[taskId] ?? null) : null)); + const pr = getPrimaryTaskPR(prs ?? undefined); const setTaskPR = useAppStore((state) => state.setTaskPR); const retryRef = useRef(0); @@ -56,13 +80,20 @@ export function useTaskPR(taskId: string | null) { const client = getWebSocketClient(); if (!client) return; + // Backend returns `{prs: TaskPR[]}` — multi-repo tasks have one row per + // repo. We push each into the store so the per-repo PR icon stays in + // sync. Empty array means no watches yet (the freshness retry below + // handles the polling cadence). Legacy single-PR shape (`TaskPR` only) + // is detected via the absence of `.prs`. client - .request("github.task_pr.sync", { task_id: taskId }) + .request<{ prs?: TaskPR[] } | TaskPR | null>("github.task_pr.sync", { task_id: taskId }) .then((result) => { - if (result?.task_id) { - setTaskPR(taskId, result); - retryRef.current = 0; + const list = normalizeSyncResponse(result); + if (list.length === 0) return; + for (const pr of list) { + if (pr.task_id) setTaskPR(taskId, pr); } + retryRef.current = 0; }) .catch(() => { // Ignore - sync may fail if no watch exists @@ -97,13 +128,18 @@ export function useTaskPR(taskId: string | null) { return () => clearInterval(interval); }, [taskId, pr, refresh]); - return { pr, refresh } as { pr: TaskPR | null; refresh: () => void }; + return { pr, prs: prs ?? [], refresh } as { + pr: TaskPR | null; + prs: TaskPR[]; + refresh: () => void; + }; } -/** Read the active task's PR from the store (no fetching). */ +/** Read the active task's primary PR from the store (no fetching). */ export function useActiveTaskPR(): TaskPR | null { return useAppStore((s) => { const taskId = s.tasks.activeTaskId; - return taskId ? (s.taskPRs.byTaskId[taskId] ?? null) : null; + if (!taskId) return null; + return getPrimaryTaskPR(s.taskPRs.byTaskId[taskId]); }); } diff --git a/apps/web/hooks/domains/kanban/index.ts b/apps/web/hooks/domains/kanban/index.ts index 520f6c401c..f9273c579f 100644 --- a/apps/web/hooks/domains/kanban/index.ts +++ b/apps/web/hooks/domains/kanban/index.ts @@ -1,3 +1,4 @@ export { useKanbanData } from "./use-kanban-data"; export { useKanbanActions } from "./use-kanban-actions"; export { useKanbanNavigation } from "./use-kanban-navigation"; +export { useTaskRepositories, useTaskPrimaryRepository } from "./use-task-repositories"; diff --git a/apps/web/hooks/domains/kanban/use-kanban-data.ts b/apps/web/hooks/domains/kanban/use-kanban-data.ts index 8b37eee385..3922b8c023 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-data.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-data.ts @@ -71,6 +71,7 @@ export function useKanbanData({ description: task.description, position: task.position, repositoryId: task.repositoryId, + repositories: task.repositories, primarySessionId: task.primarySessionId, sessionCount: task.sessionCount, reviewStatus: task.reviewStatus, diff --git a/apps/web/hooks/domains/kanban/use-task-repositories.ts b/apps/web/hooks/domains/kanban/use-task-repositories.ts new file mode 100644 index 0000000000..b86ef74a86 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-task-repositories.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useMemo } from "react"; +import { useAppStore } from "@/components/state-provider"; +import type { KanbanState } from "@/lib/state/slices"; + +/** + * Slim per-task repository shape carried in the kanban store. Mirrors + * KanbanState["tasks"][number]["repositories"][number]. + */ +export type KanbanTaskRepository = NonNullable< + KanbanState["tasks"][number]["repositories"] +>[number]; + +/** + * Returns the repositories linked to a task, ordered by Position. Empty + * array for repo-less tasks. The hook reads from the kanban tasks slice; + * use this instead of poking task.repositories directly so multi-repo + * consumers all share one source of truth. + */ +export function useTaskRepositories(taskId: string | null | undefined): KanbanTaskRepository[] { + const task = useAppStore((state) => + taskId + ? (state.kanban.tasks.find((t: KanbanState["tasks"][number]) => t.id === taskId) ?? null) + : null, + ); + return useMemo(() => { + const repos = task?.repositories ?? []; + return [...repos].sort((a, b) => a.position - b.position); + }, [task]); +} + +/** + * Returns the primary repository for a task (lowest position), or null. + */ +export function useTaskPrimaryRepository( + taskId: string | null | undefined, +): KanbanTaskRepository | null { + const repos = useTaskRepositories(taskId); + return repos.length > 0 ? repos[0] : null; +} diff --git a/apps/web/hooks/domains/session/use-base-branch-by-repo.ts b/apps/web/hooks/domains/session/use-base-branch-by-repo.ts new file mode 100644 index 0000000000..bdec1a0e5d --- /dev/null +++ b/apps/web/hooks/domains/session/use-base-branch-by-repo.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useMemo } from "react"; +import { useAppStore } from "@/components/state-provider"; +import type { Repository } from "@/lib/types/http"; + +/** + * Returns a map from `repository_name` to its task base_branch for the active + * task. Multi-repo tasks store one base_branch per repo (e.g. front: `main`, + * back: `release/24.x`); UI surfaces that show the merge target need this + * resolution to avoid pretending one workspace-level branch covers every repo. + * + * Empty for single-repo tasks (callers fall back to the workspace-level + * baseBranchDisplay) and for tasks not yet hydrated. + */ +export function useBaseBranchByRepo(activeTaskId: string | null): Record { + const tasks = useAppStore((s) => s.kanban.tasks); + const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + return useMemo(() => { + if (!activeTaskId) return {}; + const task = tasks.find((t) => t.id === activeTaskId); + if (!task?.repositories?.length) return {}; + const allRepos = Object.values(reposByWorkspace).flat() as Repository[]; + const repoNameById = new Map(allRepos.map((r) => [r.id, r.name])); + const out: Record = {}; + for (const link of task.repositories) { + const name = repoNameById.get(link.repository_id); + if (name && link.base_branch) out[name] = link.base_branch; + } + return out; + }, [activeTaskId, tasks, reposByWorkspace]); +} diff --git a/apps/web/hooks/domains/session/use-repo-display-name.ts b/apps/web/hooks/domains/session/use-repo-display-name.ts new file mode 100644 index 0000000000..f6bc1ce91c --- /dev/null +++ b/apps/web/hooks/domains/session/use-repo-display-name.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo } from "react"; +import { useAppStore } from "@/components/state-provider"; + +/** + * Resolves the workspace's primary single-repo name from the active task and + * repositories slice. Returns undefined when: + * - the task hasn't loaded yet (Bug 5 loading-order concern: `tasks` and + * `reposByWorkspace` hydrate independently via SSR + WS, so the task can be + * missing on first render) + * - the task has multiple repos (the empty-name fallback would mislabel any + * untagged row as the primary repo) + * - no matching repo entry was found in any workspace + * + * Extracted so the resolver returned by `useRepoDisplayName` can be a stable + * closure over a single primitive and React Compiler can preserve memoization. + */ +type TaskLike = { + id: string; + repositoryId?: string | null; + repositories?: unknown[]; +}; +type RepoEntry = { id: string; name: string }; + +function resolvePrimaryRepoName( + taskId: string | null, + tasks: TaskLike[], + reposByWorkspace: Record, +): string | undefined { + const task = taskId ? tasks.find((t) => t.id === taskId) : undefined; + if (task === undefined) return undefined; + const primaryRepoId = task.repositoryId ?? null; + const taskHasMultipleRepos = (task.repositories?.length ?? 0) > 1; + if (taskHasMultipleRepos || !primaryRepoId) return undefined; + for (const list of Object.values(reposByWorkspace)) { + const found = list.find((r) => r.id === primaryRepoId); + if (found) return found.name; + } + return undefined; +} + +/** + * Resolves a repository_name (as reported by agentctl in git status) to a + * human-readable label for the UI. Non-empty inputs pass through unchanged; + * empty inputs fall back to the workspace's primary repo name when safely + * resolvable, otherwise undefined (callers render a neutral "Repository"). + */ +export function useRepoDisplayName(sessionId: string | null | undefined) { + const session = useAppStore((state) => (sessionId ? state.taskSessions.items[sessionId] : null)); + const taskId = session?.task_id ?? null; + const tasks = useAppStore((state) => state.kanban.tasks); + const reposByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + // Resolve to a single primitive (or undefined) so the returned closure is + // memoized cleanly without React Compiler bailing on a branched return. + const primaryName = useMemo( + () => + resolvePrimaryRepoName( + taskId, + tasks as unknown as TaskLike[], + reposByWorkspace as unknown as Record, + ), + [taskId, tasks, reposByWorkspace], + ); + return useMemo( + () => (repositoryName: string) => repositoryName || primaryName || undefined, + [primaryName], + ); +} diff --git a/apps/web/hooks/domains/session/use-session-git-grouping.test.ts b/apps/web/hooks/domains/session/use-session-git-grouping.test.ts new file mode 100644 index 0000000000..e0c3851b8b --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-git-grouping.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { groupPathsByRepoName } from "./use-session-git"; + +describe("groupPathsByRepoName", () => { + it("splits paths by repository_name, preserving insertion order per bucket", () => { + const lookup = new Map([ + ["src/app.tsx", "frontend"], + ["src/api.ts", "frontend"], + ["handlers/task.go", "backend"], + ]); + const out = groupPathsByRepoName(["src/app.tsx", "handlers/task.go", "src/api.ts"], lookup); + expect(out.get("frontend")).toEqual(["src/app.tsx", "src/api.ts"]); + expect(out.get("backend")).toEqual(["handlers/task.go"]); + expect(out.size).toBe(2); + }); + + it("falls back to empty-string bucket for paths missing a repo", () => { + const out = groupPathsByRepoName(["unknown.txt"], new Map()); + expect(out.get("")).toEqual(["unknown.txt"]); + }); + + it("preserves single-repo callers (everything in one bucket)", () => { + const lookup = new Map([ + ["a.ts", "only"], + ["b.ts", "only"], + ]); + const out = groupPathsByRepoName(["a.ts", "b.ts"], lookup); + expect(out.size).toBe(1); + expect(out.get("only")).toEqual(["a.ts", "b.ts"]); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-git-status.ts b/apps/web/hooks/domains/session/use-session-git-status.ts index ae721a2bab..17832707e8 100644 --- a/apps/web/hooks/domains/session/use-session-git-status.ts +++ b/apps/web/hooks/domains/session/use-session-git-status.ts @@ -1,11 +1,15 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "@/components/state-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; +import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; /** * Hook to get the current git status for a session. * Git status is keyed by environment ID so sessions sharing an environment share git state. + * + * For multi-repo workspaces this returns whichever repo's status arrived last; + * use useSessionGitStatusByRepo when the caller needs all repos at once. */ export function useSessionGitStatus(sessionId: string | null) { const gitStatus = useAppStore( @@ -37,3 +41,30 @@ export function useSessionGitStatus(sessionId: string | null) { return gitStatus; } + +/** + * Hook to get per-repository git statuses for a multi-repo session. + * Returns an array of { repository_name, status } sorted by repo name. + * + * For single-repo workspaces returns a single-element array (or empty when + * no status has arrived yet). The Changes panel uses this to merge files + * from all repos and tag each with its repository, so the file tree's + * existing per-repo grouping (Phase 6) kicks in automatically. + */ +export function useSessionGitStatusByRepo( + sessionId: string | null, +): Array<{ repository_name: string; status: GitStatusEntry }> { + const map = useAppStore( + useShallow((state) => { + if (!sessionId) return undefined; + const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId; + return state.gitStatus.byEnvironmentRepo[envKey]; + }), + ); + return useMemo(() => { + if (!map) return []; + return Object.entries(map) + .map(([name, status]) => ({ repository_name: name, status })) + .sort((a, b) => a.repository_name.localeCompare(b.repository_name)); + }, [map]); +} diff --git a/apps/web/hooks/domains/session/use-session-git.ts b/apps/web/hooks/domains/session/use-session-git.ts index ff65b2d747..120fba00b8 100644 --- a/apps/web/hooks/domains/session/use-session-git.ts +++ b/apps/web/hooks/domains/session/use-session-git.ts @@ -1,7 +1,7 @@ "use client"; -import { useState, useCallback, useEffect, useMemo } from "react"; -import { useSessionGitStatus } from "./use-session-git-status"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useSessionGitStatus, useSessionGitStatusByRepo } from "./use-session-git-status"; import { useSessionCommits } from "./use-session-commits"; import { useCumulativeDiff } from "./use-cumulative-diff"; import { useGitOperations } from "@/hooks/use-git-operations"; @@ -10,9 +10,36 @@ import type { SessionCommit, CumulativeDiff, } from "@/lib/state/slices/session-runtime/types"; -import type { GitOperationResult, PRCreateResult } from "@/hooks/use-git-operations"; +import type { + GitOperationResult as RawGitOperationResult, + PRCreateResult, +} from "@/hooks/use-git-operations"; -export type { GitOperationResult, PRCreateResult }; +/** + * Per-repo result emitted by frontend-side fan-outs (commit, push, pull, + * rebase, merge, abort, stage-all, unstage-all). Each entry mirrors the wire + * shape returned by agentctl for one repo so the toast layer can describe + * partial successes ("succeeded in [A], failed in [B]: …"). + */ +export type PerRepoOperationResult = { + repository_name: string; + success: boolean; + output: string; + error?: string; +}; + +/** + * Aggregated result of a multi-repo fan-out. The `success` / `output` / + * `error` fields preserve the legacy `GitOperationResult` shape so existing + * single-repo callers keep working unchanged. The optional `per_repo` field + * is frontend-internal — it never goes back to the backend wire and only + * appears when the op was fanned out across more than one repo. + */ +export type GitOperationResult = RawGitOperationResult & { + per_repo?: PerRepoOperationResult[]; +}; + +export type { PRCreateResult }; export type SessionGit = { // Branch info @@ -47,104 +74,602 @@ export type SessionGit = { loadingOperation: string | null; pendingStageFiles: Set; - // Actions - pull: (rebase?: boolean) => Promise; - push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise; - rebase: (baseBranch: string) => Promise; - merge: (baseBranch: string) => Promise; - abort: (operation: "merge" | "rebase") => Promise; - commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise; - stage: (paths?: string[]) => Promise; - stageFile: (paths: string[]) => Promise; + // Actions. The optional `repo` param scopes the op to a single repo + // subpath in multi-repo workspaces; omit for single-repo. + // When `repo` is omitted in multi-repo mode, these fan out across every repo + // (push gates on ahead > 0; pull/rebase/merge/abort hit every repo). + pull: (rebase?: boolean, repo?: string) => Promise; + push: ( + options?: { force?: boolean; setUpstream?: boolean }, + repo?: string, + ) => Promise; + rebase: (baseBranch: string, repo?: string) => Promise; + merge: (baseBranch: string, repo?: string) => Promise; + abort: (operation: "merge" | "rebase", repo?: string) => Promise; + /** Distinct repo names present in the session's files; empty for single-repo. */ + repoNames: string[]; + /** Per-repo branch / ahead / behind / hasStaged for header buttons. */ + perRepoStatus: Array<{ + repository_name: string; + branch: string | null; + ahead: number; + behind: number; + hasStaged: boolean; + hasUnstaged: boolean; + }>; + // Multi-repo: when `repo` is omitted, commit fans out one call per repo with + // staged changes so each repo gets its own commit. With `repo`, only that + // repo is committed. + commit: ( + message: string, + stageAll?: boolean, + amend?: boolean, + repo?: string, + ) => Promise; + // Multi-repo: when `repo` is provided, the op runs against that repo only — + // use this for per-file actions to avoid path-based lookup collisions + // (same-named files like README.md exist in multiple repos). When `repo` + // is omitted, the op falls back to a path-based lookup against allFiles + // (works for unique paths) or fans out across every repo present. + stage: (paths?: string[], repo?: string) => Promise; + stageFile: (paths: string[], repo?: string) => Promise; stageAll: () => Promise; - unstage: (paths?: string[]) => Promise; - unstageFile: (paths: string[]) => Promise; + unstage: (paths?: string[], repo?: string) => Promise; + unstageFile: (paths: string[], repo?: string) => Promise; unstageAll: () => Promise; - discard: (paths?: string[]) => Promise; - revertCommit: (commitSHA: string) => Promise; + discard: (paths?: string[], repo?: string) => Promise; + revertCommit: (commitSHA: string, repo?: string) => Promise; renameBranch: (newName: string) => Promise; - reset: (commitSHA: string, mode: "soft" | "hard") => Promise; + reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise; createPR: ( title: string, body: string, baseBranch?: string, draft?: boolean, + repo?: string, ) => Promise; }; +/** + * Groups paths into per-repo buckets using a path → repository_name lookup. + * Paths missing a known repo land under "" (the single-repo bucket) so legacy + * single-repo workspaces and stray entries stay correct. Insertion order in + * `paths` is preserved within each bucket. + * + * Exported for testing — also used internally by useSessionGit's stage/unstage + * fan-out, where every per-repo bucket becomes one agentctl call. + */ +export function groupPathsByRepoName( + paths: string[], + repoForPath: Map, +): Map { + const buckets = new Map(); + for (const p of paths) { + const repo = repoForPath.get(p) ?? ""; + const list = buckets.get(repo); + if (list) list.push(p); + else buckets.set(repo, [p]); + } + return buckets; +} + +/** + * Builds the SessionGit's flat file list. For multi-repo workspaces it + * stamps each FileInfo with its repository_name so consumers can group; + * for single-repo it returns the legacy single-status files unchanged. + */ +function aggregateFilesAcrossRepos( + statusByRepo: ReturnType, + gitStatus: ReturnType, +): FileInfo[] { + if (statusByRepo.length > 0) { + const out: FileInfo[] = []; + for (const { repository_name, status } of statusByRepo) { + if (!status?.files) continue; + for (const f of Object.values(status.files)) { + out.push(repository_name ? { ...f, repository_name } : f); + } + } + return out; + } + return gitStatus?.files ? Object.values(gitStatus.files) : []; +} + +type StageDispatchArgs = { + gitOps: ReturnType; + repoForPath: Map; + reposInFiles: string[]; + stagedFiles: FileInfo[]; + setPendingStageFiles: React.Dispatch>>; +}; + +/** + * Encodes a (repo, path) pair as a single Set entry. We need a per-repo key + * because two repos can have files at the same relative path (README.md, + * .gitignore, etc.) and a flat path-only Set would conflate them. The + * separator "::" keeps the key trivially parseable for debugging — repo + * names don't contain "::". + */ +export function pendingKey(repo: string | undefined, path: string): string { + return `${repo ?? ""}::${path}`; +} + +/** + * Aggregates a list of per-repo results into a single GitOperationResult. + * - `success` is true only if every repo succeeded + * - `output` is the joined output across repos (one line each, prefixed with the repo name) + * - `error` is the first failure's error + * - `per_repo` carries the full per-repo breakdown for the toast layer + * + * Single-repo (one entry) returns the raw result with no `per_repo` field so + * the toast handler renders the legacy single-line message. + */ +function aggregatePerRepoResults( + perRepo: PerRepoOperationResult[], + operation: string, +): GitOperationResult { + if (perRepo.length === 0) { + return { success: true, operation, output: "" }; + } + if (perRepo.length === 1) { + const only = perRepo[0]; + return { + success: only.success, + operation, + output: only.output, + error: only.error, + }; + } + const allSucceeded = perRepo.every((r) => r.success); + const firstFailure = perRepo.find((r) => !r.success); + const joined = perRepo + .map((r) => `[${r.repository_name || "default"}] ${r.output}`.trim()) + .filter(Boolean) + .join("\n"); + return { + success: allSucceeded, + operation, + output: joined, + error: firstFailure?.error, + per_repo: perRepo, + }; +} + +/** + * Runs `op` against each repo in `repos`, collecting per-repo results. + * Continues past failures so partial-success state is surfaced (instead of + * stopping at the first error and leaving the user blind to repo A having + * already mutated). + */ +async function fanOutAcrossRepos( + repos: string[], + operation: string, + op: (repo: string) => Promise, +): Promise { + const perRepo: PerRepoOperationResult[] = []; + for (const repo of repos) { + try { + const r = await op(repo); + perRepo.push({ + repository_name: repo, + success: r.success, + output: r.output, + error: r.error, + }); + } catch (e) { + perRepo.push({ + repository_name: repo, + success: false, + output: "", + error: e instanceof Error ? e.message : String(e), + }); + } + } + return aggregatePerRepoResults(perRepo, operation); +} + +/** + * Multi-repo dispatch for stage/unstage/commit/discard. The fan-out logic + * (stage-all across every repo, commit per-repo with staged changes, etc.) + * lives here so the parent hook stays small. + */ +// eslint-disable-next-line max-lines-per-function -- dispatch table, splitting further hurts readability +function useStageDispatch({ + gitOps, + repoForPath, + reposInFiles, + stagedFiles, + setPendingStageFiles, +}: StageDispatchArgs) { + const groupPathsByRepo = useCallback( + (paths: string[]): Map => groupPathsByRepoName(paths, repoForPath), + [repoForPath], + ); + // stage-all / unstage-all fan out across every repo with files. Per-repo + // failures are collected and surfaced via `per_repo` instead of overwriting + // each iteration (Bug 3) — the toast layer renders partial-success cleanly. + const stageAll = useCallback( + async (): Promise => { + if (reposInFiles.length <= 1) return gitOps.stage(undefined, reposInFiles[0]); + return fanOutAcrossRepos(reposInFiles, "stage", (r) => gitOps.stage(undefined, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [reposInFiles, gitOps.stage], + ); + const unstageAll = useCallback( + async (): Promise => { + if (reposInFiles.length <= 1) return gitOps.unstage(undefined, reposInFiles[0]); + return fanOutAcrossRepos(reposInFiles, "unstage", (r) => gitOps.unstage(undefined, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [reposInFiles, gitOps.unstage], + ); + // commit fan-out: when no `repo` is given and multiple repos have staged + // changes, commit each one. Bug 2: previously we stopped at first failure + // and returned only that result, so a partial success ("repo A committed, + // repo B failed") looked like a total failure. Now we continue and + // aggregate, preserving each per-repo result for the toast layer. + const commit = useCallback( + async ( + message: string, + stageAllOpt: boolean = true, + amend: boolean = false, + repo?: string, + ): Promise => { + if (repo !== undefined) return gitOps.commit(message, stageAllOpt, amend, repo || undefined); + const reposWithStaged = Array.from( + new Set(stagedFiles.map((f) => f.repository_name).filter((n): n is string => Boolean(n))), + ); + if (reposWithStaged.length === 0) return gitOps.commit(message, stageAllOpt, amend); + if (reposWithStaged.length === 1) { + return gitOps.commit(message, stageAllOpt, amend, reposWithStaged[0]); + } + return fanOutAcrossRepos(reposWithStaged, "commit", (r) => + gitOps.commit(message, stageAllOpt, amend, r), + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.commit, stagedFiles], + ); + const runPerRepo = useCallback( + async ( + paths: string[], + explicitRepo: string | undefined, + op: (paths: string[], repo: string | undefined) => Promise, + ): Promise => { + if (explicitRepo !== undefined) return op(paths, explicitRepo || undefined); + const buckets = groupPathsByRepo(paths); + let last: GitOperationResult | undefined; + for (const [repo, repoPaths] of buckets) last = await op(repoPaths, repo || undefined); + return last as GitOperationResult; + }, + [groupPathsByRepo], + ); + const wrapPending = useCallback( + async ( + paths: string[], + repo: string | undefined, + op: (rp: string[], r: string | undefined) => Promise, + ) => { + // Bug 6: key pending entries by `repo::path` so an in-flight stage in + // repo B isn't cleared when repo A's status update lands. The consumer + // `FileRow` checks membership via the same encoding. + const buckets = repo !== undefined ? new Map([[repo, paths]]) : groupPathsByRepo(paths); + const keys: string[] = []; + for (const [r, rp] of buckets) for (const p of rp) keys.push(pendingKey(r, p)); + setPendingStageFiles((prev) => { + const next = new Set(prev); + for (const k of keys) next.add(k); + return next; + }); + try { + return await runPerRepo(paths, repo, op); + } catch (err) { + setPendingStageFiles((prev) => { + const next = new Set(prev); + for (const k of keys) next.delete(k); + return next; + }); + throw err; + } + }, + [runPerRepo, setPendingStageFiles, groupPathsByRepo], + ); + const stageFile = useCallback( + async (paths: string[], repo?: string) => + wrapPending(paths, repo, (rp, r) => gitOps.stage(rp, r)), + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.stage, wrapPending], + ); + const unstageFile = useCallback( + async (paths: string[], repo?: string) => + wrapPending(paths, repo, (rp, r) => gitOps.unstage(rp, r)), + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.unstage, wrapPending], + ); + const discard = useCallback( + async (paths?: string[], repo?: string) => { + if (!paths || paths.length === 0) return gitOps.discard(paths, repo); + return runPerRepo(paths, repo, (rp, r) => gitOps.discard(rp, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.discard, runPerRepo], + ); + return { stageAll, unstageAll, commit, stageFile, unstageFile, discard }; +} + +/** + * Multi-repo summary for the per-repo Pull/Push/Commit controls. Returns the + * full list of repo names known to the session (even ones with no file + * changes) plus a per-repo `branch / ahead / behind / hasStaged / hasUnstaged` + * row for each. Empty for single-repo workspaces. + */ +function useMultiRepoSummary( + statusByRepo: ReturnType, + allFiles: FileInfo[], + reposInFiles: string[], +) { + // Include every repo known to the session, including the empty-name entry + // for single-repo. Components render the same per-repo group structure in + // both modes — the empty name routes ops to the workspace root, named + // entries route to their respective subdirectories. + // + // Defensive filter: when there are named entries (multi-repo), drop the + // empty entry. The bare task-root tracker is supposed to stay quiet for + // multi-repo (see workspace_tracker.go's gitIndexPath guard) but a stale + // entry can linger from older builds or a bugged code path; without this + // filter the dropdown would show an extra "Repository" / primary-name row + // alongside the real per-repo entries. + const repoNamesForControls = useMemo(() => { + const seen = new Set(); + for (const { repository_name } of statusByRepo) seen.add(repository_name); + for (const r of reposInFiles) seen.add(r); + const all = Array.from(seen).sort((a, b) => a.localeCompare(b)); + const named = all.filter((r) => r !== ""); + return named.length > 0 ? named : all; + }, [statusByRepo, reposInFiles]); + + const perRepoStatus = useMemo(() => { + if (statusByRepo.length === 0) return []; + const stagedByRepo = new Map(); + const unstagedByRepo = new Map(); + for (const f of allFiles) { + const r = f.repository_name ?? ""; + if (f.staged) stagedByRepo.set(r, true); + else unstagedByRepo.set(r, true); + } + const hasNamed = statusByRepo.some((s) => s.repository_name !== ""); + const filtered = hasNamed ? statusByRepo.filter((s) => s.repository_name !== "") : statusByRepo; + return filtered.map(({ repository_name, status }) => ({ + repository_name, + branch: status?.branch ?? null, + ahead: status?.ahead ?? 0, + behind: status?.behind ?? 0, + hasStaged: stagedByRepo.get(repository_name) ?? false, + hasUnstaged: unstagedByRepo.get(repository_name) ?? false, + })); + }, [statusByRepo, allFiles]); + + return { repoNamesForControls, perRepoStatus }; +} + +/** + * Bug 6: clears pending stage markers per-repo, not globally. Each repo's + * status streams in independently as agentctl finishes per-repo ops; if we + * cleared the whole pending Set on any allFiles change, an in-flight stage + * in repo B would lose its spinner the moment repo A finished. Detects which + * repos' status entry changed since the last render and only drops the + * corresponding `${repo}::${path}` keys. + */ +function usePerRepoPendingClear( + statusByRepo: ReturnType, + allFiles: FileInfo[], + setPendingStageFiles: React.Dispatch>>, +) { + const prevStatusRef = useRef>(new Map()); + useEffect(() => { + const next = new Map(); + const refreshed: string[] = []; + for (const { repository_name, status } of statusByRepo) { + next.set(repository_name, status); + if (prevStatusRef.current.get(repository_name) !== status) { + refreshed.push(repository_name); + } + } + // Single-repo / legacy path: gitStatus changed and there's no per-repo + // entry to track. Clear all pending — original behavior, safe because + // there's only one in-flight op at a time in single-repo. + const isLegacySingleRepo = statusByRepo.length === 0; + prevStatusRef.current = next; + if (refreshed.length === 0 && !isLegacySingleRepo) return; + setPendingStageFiles((prev) => { + if (prev.size === 0) return prev; + if (isLegacySingleRepo) return new Set(); + const out = new Set(); + for (const key of prev) { + // key shape is `${repo}::${path}`; first "::" splits repo from path. + const sep = key.indexOf("::"); + const repo = sep === -1 ? "" : key.slice(0, sep); + if (!refreshed.includes(repo)) out.add(key); + } + return out; + }); + }, [allFiles, statusByRepo, setPendingStageFiles]); +} + +type RemoteOpsArgs = { + gitOps: ReturnType; + repoNamesForControls: string[]; + perRepoStatus: Array<{ repository_name: string; ahead: number }>; +}; + +/** + * Fan-out wrappers for push/pull/rebase/merge/abort so the top-bar buttons + * (which call `git.push()` / `git.pull()` etc. without a `repo` arg) hit + * every repo in multi-repo workspaces instead of silently failing at the + * workspace root (Bug 1). Single-repo workspaces and explicit-repo callers + * fall through to the underlying `gitOps` directly. + * + * Push gates on `ahead > 0` per repo so we don't push repos with nothing to + * push. Pull/Rebase/Merge/Abort fan out across every repo unconditionally. + */ +function useRemoteOpsFanOut({ gitOps, repoNamesForControls, perRepoStatus }: RemoteOpsArgs) { + // Filter out the legacy empty-name entry; it represents the workspace root + // which isn't a real git repo in multi-repo task workspaces. We only want + // to iterate the named repos. + const namedRepos = useMemo( + () => repoNamesForControls.filter((r) => r !== ""), + [repoNamesForControls], + ); + const isMultiRepo = namedRepos.length > 1; + const aheadByRepo = useMemo(() => { + const m = new Map(); + for (const s of perRepoStatus) m.set(s.repository_name, s.ahead); + return m; + }, [perRepoStatus]); + + const pull = useCallback( + async (rebase = false, repo?: string): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.pull(rebase, repo); + return fanOutAcrossRepos(namedRepos, "pull", (r) => gitOps.pull(rebase, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.pull, isMultiRepo, namedRepos], + ); + + const push = useCallback( + async ( + options?: { force?: boolean; setUpstream?: boolean }, + repo?: string, + ): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.push(options, repo); + const reposWithAhead = namedRepos.filter((r) => (aheadByRepo.get(r) ?? 0) > 0); + if (reposWithAhead.length === 0) { + return { success: true, operation: "push", output: "No commits to push" }; + } + return fanOutAcrossRepos(reposWithAhead, "push", (r) => gitOps.push(options, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.push, isMultiRepo, namedRepos, aheadByRepo], + ); + + const rebase = useCallback( + async (baseBranch: string, repo?: string): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.rebase(baseBranch, repo); + return fanOutAcrossRepos(namedRepos, "rebase", (r) => gitOps.rebase(baseBranch, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.rebase, isMultiRepo, namedRepos], + ); + + const merge = useCallback( + async (baseBranch: string, repo?: string): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.merge(baseBranch, repo); + return fanOutAcrossRepos(namedRepos, "merge", (r) => gitOps.merge(baseBranch, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.merge, isMultiRepo, namedRepos], + ); + + const abort = useCallback( + async (operation: "merge" | "rebase", repo?: string): Promise => { + if (repo !== undefined || !isMultiRepo) return gitOps.abort(operation, repo); + return fanOutAcrossRepos(namedRepos, "abort", (r) => gitOps.abort(operation, r)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable fn ref + [gitOps.abort, isMultiRepo, namedRepos], + ); + + return { pull, push, rebase, merge, abort }; +} + +/** + * Bundles the file/multirepo derivations the parent hook needs: aggregated + * file list, staged/unstaged splits, per-path repo lookup, distinct repo + * names, and the multi-repo summary used by per-repo header buttons. + */ +function useFileDerivations( + statusByRepo: ReturnType, + gitStatus: ReturnType, +) { + const allFiles = useMemo( + () => aggregateFilesAcrossRepos(statusByRepo, gitStatus), + [statusByRepo, gitStatus], + ); + const unstagedFiles = useMemo(() => allFiles.filter((f) => !f.staged), [allFiles]); + const stagedFiles = useMemo(() => allFiles.filter((f) => f.staged), [allFiles]); + const repoForPath = useMemo(() => { + const m = new Map(); + for (const f of allFiles) { + if (f.repository_name) m.set(f.path, f.repository_name); + } + return m; + }, [allFiles]); + const reposInFiles = useMemo(() => { + const seen = new Set(); + for (const f of allFiles) if (f.repository_name) seen.add(f.repository_name); + return Array.from(seen); + }, [allFiles]); + const { repoNamesForControls, perRepoStatus } = useMultiRepoSummary( + statusByRepo, + allFiles, + reposInFiles, + ); + return { + allFiles, + unstagedFiles, + stagedFiles, + repoForPath, + reposInFiles, + repoNamesForControls, + perRepoStatus, + }; +} + export function useSessionGit(sessionId: string | null | undefined): SessionGit { const sid = sessionId ?? null; const gitStatus = useSessionGitStatus(sid); + const statusByRepo = useSessionGitStatusByRepo(sid); const { commits, loading: commitsLoading } = useSessionCommits(sid); const { diff: cumulativeDiff } = useCumulativeDiff(sid); const gitOps = useGitOperations(sid); - const [pendingStageFiles, setPendingStageFiles] = useState>(new Set()); - - const allFiles = useMemo( - () => (gitStatus?.files ? Object.values(gitStatus.files) : []), - [gitStatus], - ); - const unstagedFiles = useMemo(() => allFiles.filter((f) => !f.staged), [allFiles]); - const stagedFiles = useMemo(() => allFiles.filter((f) => f.staged), [allFiles]); - - // Clear pending indicators when git status updates (files changed) - useEffect(() => { - if (pendingStageFiles.size > 0) setPendingStageFiles(new Set()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allFiles]); - + const { + allFiles, + unstagedFiles, + stagedFiles, + repoForPath, + reposInFiles, + repoNamesForControls, + perRepoStatus, + } = useFileDerivations(statusByRepo, gitStatus); + usePerRepoPendingClear(statusByRepo, allFiles, setPendingStageFiles); const ahead = gitStatus?.ahead ?? 0; const hasUnstaged = unstagedFiles.length > 0; const hasStaged = stagedFiles.length > 0; const hasCommits = commits.length > 0; - // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object - const stageAll = useCallback(async () => gitOps.stage(), [gitOps.stage]); - // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object - const unstageAll = useCallback(async () => gitOps.unstage(), [gitOps.unstage]); - - const stageFile = useCallback( - async (paths: string[]) => { - for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p)); - try { - return await gitOps.stage(paths); - } catch (err) { - setPendingStageFiles((prev) => { - const next = new Set(prev); - for (const p of paths) next.delete(p); - return next; - }); - throw err; - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object - [gitOps.stage], - ); - - const unstageFile = useCallback( - async (paths: string[]) => { - for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p)); - try { - return await gitOps.unstage(paths); - } catch (err) { - setPendingStageFiles((prev) => { - const next = new Set(prev); - for (const p of paths) next.delete(p); - return next; - }); - throw err; - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on stable fn ref, not the whole gitOps object - [gitOps.unstage], - ); + const stageOps = useStageDispatch({ + gitOps, + repoForPath, + reposInFiles, + stagedFiles, + setPendingStageFiles, + }); + const { stageAll, unstageAll, commit, stageFile, unstageFile, discard } = stageOps; + const remoteOps = useRemoteOpsFanOut({ + gitOps, + repoNamesForControls, + perRepoStatus, + }); return { branch: gitStatus?.branch ?? null, remoteBranch: gitStatus?.remote_branch ?? null, ahead, behind: gitStatus?.behind ?? 0, + repoNames: repoNamesForControls, + perRepoStatus, allFiles, unstagedFiles, @@ -168,19 +693,31 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit loadingOperation: gitOps.loadingOperation, pendingStageFiles, - pull: gitOps.pull, - push: gitOps.push, - rebase: gitOps.rebase, - merge: gitOps.merge, - abort: gitOps.abort, - commit: gitOps.commit, - stage: gitOps.stage, + pull: remoteOps.pull, + push: remoteOps.push, + rebase: remoteOps.rebase, + merge: remoteOps.merge, + abort: remoteOps.abort, + commit, + // stage/unstage with no paths and a `repo` arg = stage-all/unstage-all + // for that single repo (one agentctl call). Without `repo`, we fan out + // across every repo with files (multi-repo) or hit the workspace root + // (single-repo). With paths, we route to the right repo per file. + stage: (paths?: string[], repo?: string) => { + if (paths && paths.length > 0) return stageFile(paths, repo); + if (repo) return gitOps.stage(undefined, repo); + return stageAll(); + }, stageFile, stageAll, - unstage: gitOps.unstage, + unstage: (paths?: string[], repo?: string) => { + if (paths && paths.length > 0) return unstageFile(paths, repo); + if (repo) return gitOps.unstage(undefined, repo); + return unstageAll(); + }, unstageFile, unstageAll, - discard: gitOps.discard, + discard, revertCommit: gitOps.revertCommit, renameBranch: gitOps.renameBranch, reset: gitOps.reset, diff --git a/apps/web/hooks/domains/workspace/use-repository-branches.ts b/apps/web/hooks/domains/workspace/use-repository-branches.ts index 05bf41f5c0..28e8003cb8 100644 --- a/apps/web/hooks/domains/workspace/use-repository-branches.ts +++ b/apps/web/hooks/domains/workspace/use-repository-branches.ts @@ -1,90 +1,100 @@ +"use client"; + import { useCallback, useEffect, useRef } from "react"; import { useAppStore } from "@/components/state-provider"; -import { listRepositoryBranches } from "@/lib/api"; +import { listBranches, listRepositoryBranches } from "@/lib/api"; import type { Branch } from "@/lib/types/http"; const EMPTY_BRANCHES: Branch[] = []; -// Stale-while-revalidate: when the cache is older than this we fire a -// background refresh on hook mount/repo change. The backend additionally -// enforces a per-repo cooldown so this is the soft, client-side check. -const CLIENT_STALE_MS = 60_000; +/** + * Source of branches for a row: either a workspace repo (by id) or an + * on-machine folder (by path). Both routes go through one backend endpoint + * (`/workspaces/:id/branches`) and share one Zustand cache slice — id-based + * entries are keyed by the repo id, path-based entries get a synthetic key. + * + * `workspaceId` is always required because the route segment needs it. + */ +export type BranchSource = + | { kind: "id"; workspaceId: string; repositoryId: string } + | { kind: "path"; workspaceId: string; path: string }; -export function useRepositoryBranches(repositoryId: string | null, enabled = true) { +function cacheKeyFor(source: BranchSource | null): string { + if (!source) return ""; + return source.kind === "id" ? source.repositoryId : `path::${source.workspaceId}::${source.path}`; +} + +/** + * Loads git branches for a workspace repo or an on-machine path. One hook, + * one cache, one backend endpoint — the source shape decides which query + * param goes on the wire and which key the cache uses. + */ +export type UseBranchesResult = { + branches: Branch[]; + isLoading: boolean; + /** + * Force-refreshes the branch list with a `git fetch` first. Only available + * for id-based sources (workspace-imported repos); on-machine path sources + * resolve to `undefined` since the refresh endpoint takes a repository id. + */ + refresh?: () => Promise; +}; + +export function useBranches(source: BranchSource | null, enabled = true): UseBranchesResult { + const key = cacheKeyFor(source); const branches = useAppStore((state) => - repositoryId - ? (state.repositoryBranches.itemsByRepositoryId[repositoryId] ?? EMPTY_BRANCHES) - : EMPTY_BRANCHES, + key ? (state.repositoryBranches.itemsByRepositoryId[key] ?? EMPTY_BRANCHES) : EMPTY_BRANCHES, ); const isLoaded = useAppStore((state) => - repositoryId ? (state.repositoryBranches.loadedByRepositoryId[repositoryId] ?? false) : false, + key ? (state.repositoryBranches.loadedByRepositoryId[key] ?? false) : false, ); const isLoading = useAppStore((state) => - repositoryId ? (state.repositoryBranches.loadingByRepositoryId[repositoryId] ?? false) : false, - ); - const fetchedAt = useAppStore((state) => - repositoryId ? state.repositoryBranches.fetchedAtByRepositoryId[repositoryId] : undefined, - ); - const fetchError = useAppStore((state) => - repositoryId ? state.repositoryBranches.fetchErrorByRepositoryId[repositoryId] : undefined, + key ? (state.repositoryBranches.loadingByRepositoryId[key] ?? false) : false, ); const setRepositoryBranches = useAppStore((state) => state.setRepositoryBranches); const setRepositoryBranchesLoading = useAppStore((state) => state.setRepositoryBranchesLoading); - const setRepositoryBranchesFetchError = useAppStore( - (state) => state.setRepositoryBranchesFetchError, - ); - const inFlightRef = useRef(null); - - // Stable fetcher closed over the store actions; the repo id is a parameter - // so the same identity works for both mount-time fetches and refresh(). - const runFetch = useCallback( - (repoId: string, refresh: boolean) => { - if (inFlightRef.current === repoId) return; - inFlightRef.current = repoId; - setRepositoryBranchesLoading(repoId, true); - listRepositoryBranches(repoId, { refresh }, { cache: "no-store" }) - .then((response) => { - setRepositoryBranches(repoId, response.branches, { - fetchedAt: response.fetched_at, - fetchError: response.fetch_error, - }); - }) - .catch((err: unknown) => { - // Stale-while-revalidate: keep any previously-cached branches in - // place on transport failure so the dropdown stays usable. Only the - // fetchError is updated so callers can surface the failure. - const message = err instanceof Error ? err.message : "request failed"; - setRepositoryBranchesFetchError(repoId, message); - }) - .finally(() => { - if (inFlightRef.current === repoId) inFlightRef.current = null; - setRepositoryBranchesLoading(repoId, false); - }); - }, - [setRepositoryBranches, setRepositoryBranchesLoading, setRepositoryBranchesFetchError], - ); + const inFlightRef = useRef(false); useEffect(() => { - if (!enabled || !repositoryId) return; - // Cold load: nothing in the cache, do a foreground fetch with refresh so - // the user sees fresh remote branches the first time the dialog opens. - if (!isLoaded) { - runFetch(repositoryId, true); - return; - } - // Warm cache, possibly stale: revalidate in the background. The backend - // cooldown ensures this doesn't hammer git when the dialog is reopened - // in quick succession. - const ageMs = fetchedAt ? Date.now() - new Date(fetchedAt).getTime() : Infinity; - if (ageMs >= CLIENT_STALE_MS) { - runFetch(repositoryId, true); - } - }, [enabled, isLoaded, repositoryId, fetchedAt, runFetch]); + if (!enabled || !source) return; + if (isLoaded || inFlightRef.current) return; + inFlightRef.current = true; + setRepositoryBranchesLoading(key, true); - const refresh = useCallback(() => { - if (!repositoryId) return; - runFetch(repositoryId, true); - }, [repositoryId, runFetch]); + const promise = + source.kind === "id" + ? listBranches(source.workspaceId, { repositoryId: source.repositoryId }) + : listBranches(source.workspaceId, { path: source.path }); - return { branches, isLoading, fetchedAt, fetchError, refresh }; + promise + .then((response) => setRepositoryBranches(key, response.branches)) + .catch(() => setRepositoryBranches(key, [])) + .finally(() => { + inFlightRef.current = false; + setRepositoryBranchesLoading(key, false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- key encodes source identity; listing every field re-fires on every render + }, [enabled, isLoaded, key, setRepositoryBranches, setRepositoryBranchesLoading]); + + const refresh = useCallback(async () => { + if (!source || source.kind !== "id") return; + setRepositoryBranchesLoading(key, true); + try { + const response = await listRepositoryBranches(source.repositoryId, { refresh: true }); + setRepositoryBranches(key, response.branches); + } catch { + // Refresh failures leave the existing branch list in place; the user + // can retry manually. Errors are surfaced via the BranchRefreshButton's + // tooltip when wired with `fetchError`, but the hook does not own + // error state today. + } finally { + setRepositoryBranchesLoading(key, false); + } + }, [source, key, setRepositoryBranches, setRepositoryBranchesLoading]); + + return { + branches, + isLoading, + refresh: source?.kind === "id" ? refresh : undefined, + }; } diff --git a/apps/web/hooks/use-git-operations.ts b/apps/web/hooks/use-git-operations.ts index 1619872701..f3817f685f 100644 --- a/apps/web/hooks/use-git-operations.ts +++ b/apps/web/hooks/use-git-operations.ts @@ -21,24 +21,35 @@ export interface PRCreateResult { } interface UseGitOperationsReturn { - // Operation methods - pull: (rebase?: boolean) => Promise; - push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise; - rebase: (baseBranch: string) => Promise; - merge: (baseBranch: string) => Promise; - abort: (operation: "merge" | "rebase") => Promise; - commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise; - stage: (paths?: string[]) => Promise; - unstage: (paths?: string[]) => Promise; - discard: (paths?: string[]) => Promise; - revertCommit: (commitSHA: string) => Promise; + // Operation methods. The optional `repo` parameter is the multi-repo subpath + // (e.g. "kandev"); pass empty/undefined for single-repo workspaces. Multi-repo + // workspaces MUST scope each call to one repo — bulk callers fan out themselves. + pull: (rebase?: boolean, repo?: string) => Promise; + push: ( + options?: { force?: boolean; setUpstream?: boolean }, + repo?: string, + ) => Promise; + rebase: (baseBranch: string, repo?: string) => Promise; + merge: (baseBranch: string, repo?: string) => Promise; + abort: (operation: "merge" | "rebase", repo?: string) => Promise; + commit: ( + message: string, + stageAll?: boolean, + amend?: boolean, + repo?: string, + ) => Promise; + stage: (paths?: string[], repo?: string) => Promise; + unstage: (paths?: string[], repo?: string) => Promise; + discard: (paths?: string[], repo?: string) => Promise; + revertCommit: (commitSHA: string, repo?: string) => Promise; renameBranch: (newName: string) => Promise; - reset: (commitSHA: string, mode: "soft" | "hard") => Promise; + reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise; createPR: ( title: string, body: string, baseBranch?: string, draft?: boolean, + repo?: string, ) => Promise; // State @@ -54,60 +65,92 @@ type ExecuteOperation = ( ) => Promise; function buildGitOperationCallbacks(executeOperation: ExecuteOperation) { - const pull = async (rebase = false) => - executeOperation("worktree.pull", { rebase }); + const pull = async (rebase = false, repo?: string) => + executeOperation("worktree.pull", { + rebase, + ...(repo ? { repo } : {}), + }); - const push = async (options?: { force?: boolean; setUpstream?: boolean }) => + const push = async (options?: { force?: boolean; setUpstream?: boolean }, repo?: string) => executeOperation("worktree.push", { force: options?.force ?? false, set_upstream: options?.setUpstream ?? false, + ...(repo ? { repo } : {}), }); - const rebase = async (baseBranch: string) => - executeOperation("worktree.rebase", { base_branch: baseBranch }); + const rebase = async (baseBranch: string, repo?: string) => + executeOperation("worktree.rebase", { + base_branch: baseBranch, + ...(repo ? { repo } : {}), + }); - const merge = async (baseBranch: string) => - executeOperation("worktree.merge", { base_branch: baseBranch }); + const merge = async (baseBranch: string, repo?: string) => + executeOperation("worktree.merge", { + base_branch: baseBranch, + ...(repo ? { repo } : {}), + }); - const abort = async (operation: "merge" | "rebase") => - executeOperation("worktree.abort", { operation }); + const abort = async (operation: "merge" | "rebase", repo?: string) => + executeOperation("worktree.abort", { + operation, + ...(repo ? { repo } : {}), + }); - const commit = async (message: string, stageAll = true, amend = false) => + const commit = async (message: string, stageAll = true, amend = false, repo?: string) => executeOperation("worktree.commit", { message, stage_all: stageAll, amend, + ...(repo ? { repo } : {}), }); - const stage = async (paths?: string[]) => - executeOperation("worktree.stage", { paths: paths ?? [] }); + const stage = async (paths?: string[], repo?: string) => + executeOperation("worktree.stage", { + paths: paths ?? [], + ...(repo ? { repo } : {}), + }); - const unstage = async (paths?: string[]) => - executeOperation("worktree.unstage", { paths: paths ?? [] }); + const unstage = async (paths?: string[], repo?: string) => + executeOperation("worktree.unstage", { + paths: paths ?? [], + ...(repo ? { repo } : {}), + }); - const discard = async (paths?: string[]) => - executeOperation("worktree.discard", { paths: paths ?? [] }); + const discard = async (paths?: string[], repo?: string) => + executeOperation("worktree.discard", { + paths: paths ?? [], + ...(repo ? { repo } : {}), + }); - const revertCommit = async (commitSHA: string) => - executeOperation("worktree.revert_commit", { commit_sha: commitSHA }); + const revertCommit = async (commitSHA: string, repo?: string) => + executeOperation("worktree.revert_commit", { + commit_sha: commitSHA, + ...(repo ? { repo } : {}), + }); const renameBranch = async (newName: string) => executeOperation("worktree.rename_branch", { new_name: newName }); - const reset = async (commitSHA: string, mode: "soft" | "hard") => - executeOperation("worktree.reset", { commit_sha: commitSHA, mode }); + const reset = async (commitSHA: string, mode: "soft" | "hard", repo?: string) => + executeOperation("worktree.reset", { + commit_sha: commitSHA, + mode, + ...(repo ? { repo } : {}), + }); const createPR = async ( title: string, body: string, baseBranch?: string, draft?: boolean, + repo?: string, ): Promise => executeOperation("worktree.create_pr", { title, body, base_branch: baseBranch ?? "", draft: draft ?? true, + ...(repo ? { repo } : {}), }); return { diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts index 1259073b32..c92c8d1d7d 100644 --- a/apps/web/lib/api/domains/workspace-api.ts +++ b/apps/web/lib/api/domains/workspace-api.ts @@ -37,6 +37,34 @@ export async function listRepositories( return fetchJson(url, options); } +/** + * Lists git branches for a workspace repo. Pass exactly one of `repositoryId` + * (an imported workspace repo) or `path` (an on-machine folder discovered + * but not yet imported). The backend resolves either to an absolute path and + * runs the same `listGitBranches`. Used by the chip row's per-repo branch + * picker which needs to handle both shapes. + */ +export async function listBranches( + workspaceId: string, + source: { repositoryId: string } | { path: string }, + options?: ApiRequestOptions, +) { + const params = new URLSearchParams(); + if ("repositoryId" in source) params.set("repository_id", source.repositoryId); + else params.set("path", source.path); + return fetchJson( + `/api/v1/workspaces/${workspaceId}/branches?${params.toString()}`, + options, + ); +} + +/** + * Lists git branches for an imported workspace repository, scoped by + * repository id only. Supports `refresh=true` to force a `git fetch` before + * returning the list (with the backend's per-repo cooldown applied). Used by + * single-repo flows that already have the repo id and want to drive the + * stale-while-revalidate UI in the dialog. + */ export async function listRepositoryBranches( repositoryId: string, params?: { refresh?: boolean }, diff --git a/apps/web/lib/diff/types.ts b/apps/web/lib/diff/types.ts index d72e41225f..604f33c1b0 100644 --- a/apps/web/lib/diff/types.ts +++ b/apps/web/lib/diff/types.ts @@ -48,7 +48,7 @@ export interface DiffCommentsActions { clearPending: () => void; setEditingComment: (commentId: string | null) => void; markCommentsSent: (commentIds: string[]) => void; - getCommentsForFile: (sessionId: string, filePath: string) => DiffComment[]; + getCommentsForFile: (sessionId: string, filePath: string, repositoryId?: string) => DiffComment[]; getPendingComments: () => DiffComment[]; clearSessionComments: (sessionId: string) => void; } diff --git a/apps/web/lib/group-by-repo.test.ts b/apps/web/lib/group-by-repo.test.ts new file mode 100644 index 0000000000..c4df1ab390 --- /dev/null +++ b/apps/web/lib/group-by-repo.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { groupByRepositoryName } from "./group-by-repo"; + +describe("groupByRepositoryName", () => { + it("buckets by name in first-seen order, preserving item order within each bucket", () => { + const items = [ + { id: "a", repo: "frontend" }, + { id: "b", repo: "backend" }, + { id: "c", repo: "frontend" }, + { id: "d", repo: "backend" }, + ]; + const groups = groupByRepositoryName(items, (i) => i.repo); + expect(groups.map((g) => g.repositoryName)).toEqual(["frontend", "backend"]); + expect(groups[0].items.map((i) => i.id)).toEqual(["a", "c"]); + expect(groups[1].items.map((i) => i.id)).toEqual(["b", "d"]); + }); + + it("collapses missing/undefined names into a single empty-name bucket", () => { + const items = [{ id: "x" }, { id: "y", repo: "named" }, { id: "z" }]; + const groups = groupByRepositoryName(items, (i) => (i as { repo?: string }).repo); + expect(groups.map((g) => g.repositoryName)).toEqual(["", "named"]); + expect(groups[0].items.map((i) => i.id)).toEqual(["x", "z"]); + expect(groups[1].items.map((i) => i.id)).toEqual(["y"]); + }); + + it("returns an empty array for an empty input", () => { + expect(groupByRepositoryName([], () => "x")).toEqual([]); + }); +}); diff --git a/apps/web/lib/group-by-repo.ts b/apps/web/lib/group-by-repo.ts new file mode 100644 index 0000000000..d090653c42 --- /dev/null +++ b/apps/web/lib/group-by-repo.ts @@ -0,0 +1,26 @@ +/** + * Buckets a list into per-repository groups, preserving the original item + * order within each bucket and producing groups in first-seen order. Items + * whose `getName` returns undefined or empty land in a single empty-named + * group so callers can decide whether to render a header for it. + * + * One copy of this lived in three places before — review-diff-list-groups, + * changes-panel-timeline (files), and changes-panel-timeline (commits) — + * differing only in element type. The generic shape collapses them all. + */ +export function groupByRepositoryName( + items: T[], + getName: (item: T) => string | undefined, +): Array<{ repositoryName: string; items: T[] }> { + const order: string[] = []; + const buckets = new Map(); + for (const item of items) { + const name = getName(item) ?? ""; + if (!buckets.has(name)) { + buckets.set(name, []); + order.push(name); + } + buckets.get(name)!.push(item); + } + return order.map((name) => ({ repositoryName: name, items: buckets.get(name)! })); +} diff --git a/apps/web/lib/kanban/map-task.test.ts b/apps/web/lib/kanban/map-task.test.ts index 88dd98f997..c9f33775a8 100644 --- a/apps/web/lib/kanban/map-task.test.ts +++ b/apps/web/lib/kanban/map-task.test.ts @@ -47,7 +47,10 @@ function wsPayload(overrides: Partial = {}): TaskLike { task_id: "task-1", workflow_id: "wf-1", ...BASE_SCALARS, + // Backend WS task.updated emits both the primary repository_id *and* the + // full repositories array so multi-repo state survives WS-driven updates. repository_id: "repo-a", + repositories: [{ repository_id: "repo-a" }], primary_session_id: "session-p", primary_session_state: "RUNNING", session_count: 2, @@ -95,7 +98,7 @@ describe("toKanbanTask — HTTP DTO / WS payload parity", () => { it("missing repository on either shape: repositoryId is undefined", () => { const http = httpDTO({ repositories: undefined }); - const ws = wsPayload({ repository_id: undefined }); + const ws = wsPayload({ repository_id: undefined, repositories: undefined }); expect(toKanbanTask(http).repositoryId).toBeUndefined(); expect(toKanbanTask(ws).repositoryId).toBeUndefined(); }); diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 8cff76e0a1..7d1ecaf577 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -25,7 +25,13 @@ export type TaskLike = { description?: string | null; position?: number; state?: TaskState; - repositories?: Array<{ repository_id: string }>; + repositories?: Array<{ + id?: string; + repository_id: string; + base_branch?: string; + checkout_branch?: string; + position?: number; + }>; repository_id?: string; primary_session_id?: string | null; primary_session_state?: TaskSessionState | string | null; @@ -49,6 +55,19 @@ function pickId(source: TaskLike): string { return (source.id ?? source.task_id ?? "") as string; } +type KanbanTaskRepository = NonNullable[number]; + +function pickRepositories(source: TaskLike): KanbanTaskRepository[] | undefined { + if (!source.repositories) return undefined; + return source.repositories.map((r, idx) => ({ + id: r.id ?? "", + repository_id: r.repository_id, + base_branch: r.base_branch ?? "", + checkout_branch: r.checkout_branch, + position: r.position ?? idx, + })); +} + /** * Build a canonical {@link KanbanTask} from either an HTTP DTO or a WebSocket * payload. Both paths share this helper so a single publisher change can never @@ -64,6 +83,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { position: source.position ?? 0, state: source.state, repositoryId: pickRepositoryId(source), + repositories: pickRepositories(source), primarySessionId: source.primary_session_id ?? undefined, primarySessionState: source.primary_session_state ?? undefined, sessionCount: source.session_count ?? undefined, diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index ccff7b2229..6cc403f01c 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -1,4 +1,5 @@ import type { AppState, KanbanState } from "@/lib/state/store"; +import { primaryTaskRepository } from "@/lib/types/http"; import type { WorkflowSnapshot, Message, Task } from "@/lib/types/http"; import { isPRReviewFromMetadata, @@ -26,6 +27,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { .map((task) => { const workflowStepId = task.workflow_step_id; if (!workflowStepId) return null; + const primary = primaryTaskRepository(task.repositories); return { id: task.id, workflowStepId, @@ -33,7 +35,14 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { description: task.description ?? undefined, position: task.position ?? 0, state: task.state, - repositoryId: task.repositories?.[0]?.repository_id ?? undefined, + repositoryId: primary?.repository_id ?? undefined, + repositories: task.repositories?.map((r) => ({ + id: r.id, + repository_id: r.repository_id, + base_branch: r.base_branch, + checkout_branch: r.checkout_branch, + position: r.position, + })), primarySessionId: task.primary_session_id ?? undefined, primarySessionState: task.primary_session_state ?? undefined, sessionCount: task.session_count ?? undefined, diff --git a/apps/web/lib/state/dockview-panel-actions.ts b/apps/web/lib/state/dockview-panel-actions.ts index 84eb87ef52..fb3c36d4dd 100644 --- a/apps/web/lib/state/dockview-panel-actions.ts +++ b/apps/web/lib/state/dockview-panel-actions.ts @@ -275,15 +275,19 @@ function buildFileDiffAction(get: StoreGet) { } function buildCommitDetailAction(get: StoreGet) { - return (sha: string, opts?: OpenPanelOpts & { groupId?: string }) => { + return (sha: string, opts?: OpenPanelOpts & { groupId?: string; repo?: string }) => { const { api, centerGroupId } = get(); if (!api) return; + // Multi-repo: scope the panel id by repo so the same SHA from two repos + // (rare in practice, but cheap to be correct) doesn't collide and so the + // existing-tab dedup doesn't reuse the wrong-repo's panel. + const itemId = opts?.repo ? `${opts.repo}:${sha}` : sha; openOrReplacePreview({ api, type: "commit-detail", - itemId: sha, + itemId, title: sha.slice(0, 7), - params: { commitSha: sha }, + params: { commitSha: sha, repo: opts?.repo }, groupId: opts?.groupId ?? centerGroupId, quiet: opts?.quiet, pin: opts?.pin, @@ -421,14 +425,19 @@ export function buildExtraPanelActions(get: StoreGet) { opts?.quiet ?? false, ); }, - addPRPanel: () => { + addPRPanel: (prKey?: string) => { const { api, centerGroupId } = get(); if (!api) return; + // Multi-repo: each TaskPR opens in its own panel keyed by + // owner/repo/pr_number so multiple PRs can be tabbed side-by-side. + // Legacy single-repo callers (no key) get the historical panel id. + const id = prKey ? `pr-detail|${prKey}` : "pr-detail"; focusOrAddPanel(api, { - id: "pr-detail", + id, component: "pr-detail", title: "Pull Request", position: { referenceGroup: centerGroupId }, + params: prKey ? { prKey } : undefined, }); }, addTerminalPanel: (terminalId?: string, groupId?: string, environmentId?: string) => { diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index b337dcfc91..9eb0816b6e 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -103,14 +103,18 @@ type DockviewStore = { path: string, opts?: OpenPanelOpts & { content?: string; groupId?: string }, ) => void; - addCommitDetailPanel: (sha: string, opts?: OpenPanelOpts & { groupId?: string }) => void; + addCommitDetailPanel: ( + sha: string, + opts?: OpenPanelOpts & { groupId?: string; repo?: string }, + ) => void; addFileEditorPanel: (path: string, name: string, opts?: OpenPanelOpts) => void; promotePreviewToPinned: (type: PreviewType) => void; addBrowserPanel: (url?: string, groupId?: string) => void; addVscodePanel: () => void; openInternalVscode: (goto_: { file: string; line: number; col: number } | null) => void; addPlanPanel: (opts?: { groupId?: string; quiet?: boolean; inCenter?: boolean }) => void; - addPRPanel: () => void; + /** Open a PR detail panel. prKey (owner/repo/pr_number) gives multi-repo tasks one tab per PR. */ + addPRPanel: (prKey?: string) => void; addTerminalPanel: (terminalId?: string, groupId?: string, environmentId?: string) => void; selectedDiff: { path: string; content?: string } | null; setSelectedDiff: (diff: { path: string; content?: string } | null) => void; diff --git a/apps/web/lib/state/slices/comments/comments-store.ts b/apps/web/lib/state/slices/comments/comments-store.ts index 9f4b8dbf96..46d69e253b 100644 --- a/apps/web/lib/state/slices/comments/comments-store.ts +++ b/apps/web/lib/state/slices/comments/comments-store.ts @@ -145,15 +145,22 @@ export const useCommentsStore = create()( hydrateSession: (sessionId: string) => set((state) => hydrateSessionInState(state, sessionId)), - getCommentsForFile: (sessionId: string, filePath: string): DiffComment[] => { + getCommentsForFile: ( + sessionId: string, + filePath: string, + repositoryId?: string, + ): DiffComment[] => { const state = get(); const ids = state.bySession[sessionId]; if (!ids) return []; const result: DiffComment[] = []; for (const id of ids) { const comment = state.byId[id]; - if (comment && isDiffComment(comment) && comment.filePath === filePath) - result.push(comment); + if (!comment || !isDiffComment(comment) || comment.filePath !== filePath) continue; + // When a repo is specified, scope to that repo. Comments without a + // repositoryId (legacy) match any repo so existing data keeps showing. + if (repositoryId && comment.repositoryId && comment.repositoryId !== repositoryId) continue; + result.push(comment); } return result; }, diff --git a/apps/web/lib/state/slices/comments/store-multi-repo.test.ts b/apps/web/lib/state/slices/comments/store-multi-repo.test.ts new file mode 100644 index 0000000000..b2efea2d1e --- /dev/null +++ b/apps/web/lib/state/slices/comments/store-multi-repo.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useCommentsStore } from "./comments-store"; +import type { DiffComment } from "./types"; + +const SESSION_ID = "sess"; +const FILE_PATH = "src/app.tsx"; +const REPO_FRONT = "repo-front"; + +function diffComment(overrides: Partial): DiffComment { + return { + id: "c-" + Math.random().toString(36).slice(2), + sessionId: SESSION_ID, + source: "diff", + text: "looks off", + filePath: FILE_PATH, + startLine: 1, + endLine: 1, + side: "additions", + codeContent: "const x = 1;", + createdAt: new Date().toISOString(), + status: "pending", + ...overrides, + }; +} + +describe("getCommentsForFile (multi-repo)", () => { + beforeEach(() => { + useCommentsStore.setState({ + byId: {}, + bySession: {}, + pendingForChat: [], + editingCommentId: null, + }); + }); + + it("filters by repositoryId when provided", () => { + const store = useCommentsStore.getState(); + store.addComment(diffComment({ id: "front", repositoryId: REPO_FRONT })); + store.addComment(diffComment({ id: "back", repositoryId: "repo-back" })); + + const front = useCommentsStore.getState().getCommentsForFile(SESSION_ID, FILE_PATH, REPO_FRONT); + expect(front.map((c) => c.id)).toEqual(["front"]); + + const back = useCommentsStore.getState().getCommentsForFile(SESSION_ID, FILE_PATH, "repo-back"); + expect(back.map((c) => c.id)).toEqual(["back"]); + }); + + it("returns all matching when repositoryId is omitted", () => { + const store = useCommentsStore.getState(); + store.addComment(diffComment({ id: "front", repositoryId: REPO_FRONT })); + store.addComment(diffComment({ id: "back", repositoryId: "repo-back" })); + + const all = useCommentsStore.getState().getCommentsForFile(SESSION_ID, FILE_PATH); + expect(all.map((c) => c.id).sort()).toEqual(["back", "front"]); + }); + + it("legacy comments without repositoryId match any repo filter", () => { + const store = useCommentsStore.getState(); + store.addComment(diffComment({ id: "legacy" })); // no repositoryId + + const result = useCommentsStore + .getState() + .getCommentsForFile(SESSION_ID, FILE_PATH, REPO_FRONT); + expect(result.map((c) => c.id)).toEqual(["legacy"]); + }); +}); diff --git a/apps/web/lib/state/slices/comments/types.ts b/apps/web/lib/state/slices/comments/types.ts index f29f3f82a0..eba9850a5e 100644 --- a/apps/web/lib/state/slices/comments/types.ts +++ b/apps/web/lib/state/slices/comments/types.ts @@ -14,6 +14,12 @@ export type AnnotationSide = "additions" | "deletions"; type CommentBase = { id: string; sessionId: string; + /** + * Repository this comment belongs to. Optional for backwards compat with + * comments persisted before multi-repo support. New comments always set it + * so per-repo filtering works in multi-repo task views. + */ + repositoryId?: string; text: string; createdAt: string; status: "pending" | "sent"; @@ -95,7 +101,13 @@ export type CommentsActions = { markCommentsSent: (commentIds: string[]) => void; clearSessionComments: (sessionId: string) => void; hydrateSession: (sessionId: string) => void; - getCommentsForFile: (sessionId: string, filePath: string) => DiffComment[]; + /** + * Returns diff comments for a file in a session. When repositoryId is + * provided, results are filtered to that repo only (multi-repo support). + * Omitting repositoryId matches comments regardless of repo, preserving + * single-repo callers and legacy localStorage entries. + */ + getCommentsForFile: (sessionId: string, filePath: string, repositoryId?: string) => DiffComment[]; getPendingComments: () => Comment[]; }; diff --git a/apps/web/lib/state/slices/github/github-slice.ts b/apps/web/lib/state/slices/github/github-slice.ts index bc2364619b..d9bba4d22c 100644 --- a/apps/web/lib/state/slices/github/github-slice.ts +++ b/apps/web/lib/state/slices/github/github-slice.ts @@ -41,7 +41,15 @@ function createTaskPRActions( }), setTaskPR: (taskId, pr) => set((draft) => { - draft.taskPRs.byTaskId[taskId] = pr; + // Upsert by repository_id so multi-repo PRs coexist for the same task. + // For legacy rows without a repository_id, match on the empty key (one + // such row per task max), preserving prior single-PR semantics. + const existing = draft.taskPRs.byTaskId[taskId] ?? []; + const repoKey = pr.repository_id ?? ""; + const idx = existing.findIndex((p) => (p.repository_id ?? "") === repoKey); + if (idx >= 0) existing[idx] = pr; + else existing.push(pr); + draft.taskPRs.byTaskId[taskId] = existing; }), removeTaskPR: (taskId) => set((draft) => { diff --git a/apps/web/lib/state/slices/github/types.ts b/apps/web/lib/state/slices/github/types.ts index b32323dd69..1dc75a3c1a 100644 --- a/apps/web/lib/state/slices/github/types.ts +++ b/apps/web/lib/state/slices/github/types.ts @@ -14,7 +14,8 @@ export type GitHubStatusState = { }; export type TaskPRsState = { - byTaskId: Record; + /** Each task may have multiple PRs (one per repository for multi-repo tasks). */ + byTaskId: Record; loaded: boolean; loading: boolean; }; @@ -54,7 +55,7 @@ export type GitHubSliceState = { export type GitHubSliceActions = { setGitHubStatus: (status: GitHubStatus | null) => void; setGitHubStatusLoading: (loading: boolean) => void; - setTaskPRs: (prs: Record) => void; + setTaskPRs: (prs: Record) => void; setTaskPR: (taskId: string, pr: TaskPR) => void; removeTaskPR: (taskId: string) => void; setTaskPRsLoading: (loading: boolean) => void; diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index 8ad09f480a..30ec8671e5 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -28,7 +28,20 @@ export type KanbanState = { description?: string; position: number; state?: TaskStatus; + /** Primary repository id (lowest position). Kept for backwards compat. */ repositoryId?: string; + /** + * All repositories linked to the task, ordered by Position. Optional so + * legacy SSR payloads still parse; multi-repo UI consumers should prefer + * this over repositoryId. + */ + repositories?: Array<{ + id: string; + repository_id: string; + base_branch: string; + checkout_branch?: string; + position: number; + }>; primarySessionId?: string | null; primarySessionState?: string | null; sessionCount?: number | null; diff --git a/apps/web/lib/state/slices/session-runtime/git-status-multi-repo.test.ts b/apps/web/lib/state/slices/session-runtime/git-status-multi-repo.test.ts new file mode 100644 index 0000000000..d94d8744a4 --- /dev/null +++ b/apps/web/lib/state/slices/session-runtime/git-status-multi-repo.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createSessionRuntimeSlice } from "./session-runtime-slice"; +import type { GitStatusEntry, SessionRuntimeSlice } from "./types"; + +/** + * Builds a status with the given modified files. Both `modified` (the legacy + * string list) and `files` (the FileInfo map used by hasGitStatusChanged for + * change detection) are populated so the slice's same-status guard recognises + * subsequent updates as meaningful changes. + */ +function entry(overrides: { modified: string[]; repository_name?: string }): GitStatusEntry { + const files: GitStatusEntry["files"] = {}; + for (const path of overrides.modified) { + files[path] = { path, status: "modified", staged: false }; + } + return { + branch: "main", + remote_branch: null, + modified: overrides.modified, + added: [], + deleted: [], + untracked: [], + renamed: [], + ahead: 0, + behind: 0, + files, + timestamp: new Date().toISOString() + Math.random(), + repository_name: overrides.repository_name, + }; +} + +const REPO_FRONTEND = "frontend"; +const REPO_BACKEND = "backend"; +const SESSION = "sess"; +const FRONTEND_FILE = "frontend.tsx"; +const BACKEND_FILE = "backend.go"; + +function makeStore() { + return create()( + immer((set, get, store) => createSessionRuntimeSlice(set, get, store)), + ); +} + +describe("session-runtime gitStatus multi-repo routing", () => { + let useStore: ReturnType; + + beforeEach(() => { + useStore = makeStore(); + }); + + it("routes a single status without repository_name into the legacy map only", () => { + useStore.getState().setGitStatus(SESSION, entry({ modified: ["a.ts"] })); + const state = useStore.getState(); + expect(state.gitStatus.byEnvironmentId[SESSION]?.modified).toEqual(["a.ts"]); + // Per-repo map gets an entry under the empty key (mirrors single-repo) so + // consumers that only read byEnvironmentRepo still see it. + expect(Object.keys(state.gitStatus.byEnvironmentRepo[SESSION] ?? {})).toEqual([""]); + }); + + it("routes per-repo statuses into byEnvironmentRepo keyed by repository_name", () => { + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: [FRONTEND_FILE], repository_name: REPO_FRONTEND })); + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: [BACKEND_FILE], repository_name: REPO_BACKEND })); + + const repoMap = useStore.getState().gitStatus.byEnvironmentRepo[SESSION]; + expect(Object.keys(repoMap).sort()).toEqual([REPO_BACKEND, REPO_FRONTEND]); + expect(repoMap[REPO_FRONTEND].modified).toEqual([FRONTEND_FILE]); + expect(repoMap[REPO_BACKEND].modified).toEqual([BACKEND_FILE]); + }); + + it("does NOT overwrite the per-repo map when a sibling repo updates", () => { + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: [FRONTEND_FILE], repository_name: REPO_FRONTEND })); + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: [BACKEND_FILE], repository_name: REPO_BACKEND })); + // Update frontend again — backend must still be there. + useStore + .getState() + .setGitStatus( + SESSION, + entry({ modified: ["frontend2.tsx"], repository_name: REPO_FRONTEND }), + ); + const repoMap = useStore.getState().gitStatus.byEnvironmentRepo[SESSION]; + expect(repoMap[REPO_FRONTEND].modified).toEqual(["frontend2.tsx"]); + expect(repoMap[REPO_BACKEND].modified).toEqual([BACKEND_FILE]); + }); + + it("clearGitStatus drops both maps", () => { + useStore + .getState() + .setGitStatus(SESSION, entry({ modified: ["x.ts"], repository_name: REPO_FRONTEND })); + useStore.getState().clearGitStatus(SESSION); + const state = useStore.getState(); + expect(state.gitStatus.byEnvironmentId[SESSION]).toBeUndefined(); + expect(state.gitStatus.byEnvironmentRepo[SESSION]).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index e85a201192..ac794534e1 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -77,7 +77,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = { activeProcessBySessionId: {}, devProcessBySessionId: {}, }, - gitStatus: { byEnvironmentId: {} }, + gitStatus: { byEnvironmentId: {}, byEnvironmentRepo: {} }, environmentIdBySessionId: {}, sessionCommits: { byEnvironmentId: {}, loading: {} }, contextWindow: { bySessionId: {} }, @@ -214,6 +214,7 @@ export function migrateEnvKeyedData( } }; migrate(draft.sessionCommits.byEnvironmentId); + migrate(draft.gitStatus.byEnvironmentRepo); migrate(draft.sessionCommits.loading); migrate(draft.gitStatus.byEnvironmentId); migrate(draft.shell.outputs); @@ -234,14 +235,26 @@ export const createSessionRuntimeSlice: StateCreator< setGitStatus: (sessionId, gitStatus) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; + // Multi-repo: when the update is tagged with repository_name, route it + // into the per-repo map. Single-repo updates (no name) keep the legacy + // single-status path; the per-repo map mirrors the same entry under an + // empty key so consumers using only byEnvironmentRepo still see it. + const repoName = gitStatus.repository_name ?? ""; const existing = draft.gitStatus.byEnvironmentId[envKey]; - if (existing && !hasGitStatusChanged(existing, gitStatus)) return; - draft.gitStatus.byEnvironmentId[envKey] = gitStatus; + if (!existing || hasGitStatusChanged(existing, gitStatus)) { + draft.gitStatus.byEnvironmentId[envKey] = gitStatus; + } + const repoMap = (draft.gitStatus.byEnvironmentRepo[envKey] ??= {}); + const existingRepo = repoMap[repoName]; + if (!existingRepo || hasGitStatusChanged(existingRepo, gitStatus)) { + repoMap[repoName] = gitStatus; + } }), clearGitStatus: (sessionId) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; delete draft.gitStatus.byEnvironmentId[envKey]; + delete draft.gitStatus.byEnvironmentRepo[envKey]; }), registerSessionEnvironment: (sessionId, environmentId) => set((draft) => { diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 0f7e16fe4b..77724932bd 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -49,6 +49,13 @@ export type FileInfo = { old_path?: string; diff?: string; diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded"; + /** + * Repository this file belongs to in multi-repo task workspaces. Stamped + * by useSessionGit when aggregating per-repo statuses; empty for single- + * repo workspaces. The Changes panel uses it to group files under + * per-repository headers. + */ + repository_name?: string; }; export type GitStatusEntry = { @@ -65,12 +72,26 @@ export type GitStatusEntry = { timestamp: string | null; branch_additions?: number; branch_deletions?: number; + /** + * Repository this status belongs to in multi-repo task workspaces. Empty + * for single-repo and used as the per-repo key in + * GitStatusState.byEnvironmentRepo. + */ + repository_name?: string; }; export type GitStatusState = { /** Git status keyed by environment ID (shared across sessions in the same environment). - * Falls back to session ID when no environment exists. */ + * Falls back to session ID when no environment exists. + * For multi-repo workspaces this holds the most recently received status + * (whichever repo emitted last); per-repo state lives in byEnvironmentRepo. + */ byEnvironmentId: Record; + /** + * Per-repository git status for multi-repo task workspaces, keyed by + * environment ID then repository name. Empty for single-repo workspaces. + */ + byEnvironmentRepo: Record>; }; // Git Snapshot types for historical tracking @@ -89,6 +110,8 @@ export type SessionCommit = { insertions: number; deletions: number; created_at: string; + /** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */ + repository_name?: string; }; export type CumulativeDiff = { diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 58e0e4ac83..0320eb6f6c 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -234,7 +234,7 @@ export type AppState = { // GitHub actions setGitHubStatus: (status: GitHubStatus | null) => void; setGitHubStatusLoading: (loading: boolean) => void; - setTaskPRs: (prs: Record) => void; + setTaskPRs: (prs: Record) => void; setTaskPR: (taskId: string, pr: TaskPR) => void; removeTaskPR: (taskId: string) => void; setTaskPRsLoading: (loading: boolean) => void; diff --git a/apps/web/lib/types/git-events.ts b/apps/web/lib/types/git-events.ts index 9468a06e48..93de9aa61b 100644 --- a/apps/web/lib/types/git-events.ts +++ b/apps/web/lib/types/git-events.ts @@ -24,6 +24,12 @@ export type GitStatusData = { files: Record; branch_additions?: number; branch_deletions?: number; + /** + * Repository this status belongs to in multi-repo task workspaces. + * Empty / undefined for single-repo workspaces. The frontend keys per-repo + * git status off this name so the changes panel can show all repos at once. + */ + repository_name?: string; }; // Git commit data @@ -39,6 +45,8 @@ export type GitCommitData = { deletions: number; committed_at: string; created_at?: string; + /** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */ + repository_name?: string; }; // Git reset data diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index fdb560fa09..02b898b8da 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -114,6 +114,9 @@ export type MergeableState = export type TaskPR = { id: string; task_id: string; + /** ID of the task repository this PR belongs to. Empty for legacy single-repo + * tasks persisted before multi-repo support. */ + repository_id?: string; owner: string; repo: string; pr_number: number; @@ -209,7 +212,8 @@ export type PRStats = { export type GitHubStatusResponse = GitHubStatus; export type TaskPRsResponse = { - task_prs: Record; + /** Each task may have multiple PRs (one per repository for multi-repo tasks). */ + task_prs: Record; }; export type PRWatchesResponse = { diff --git a/apps/web/lib/types/http.test.ts b/apps/web/lib/types/http.test.ts new file mode 100644 index 0000000000..e0afba8aa2 --- /dev/null +++ b/apps/web/lib/types/http.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { primaryTaskRepository, type TaskRepository } from "./http"; + +function repo(overrides: Partial): TaskRepository { + return { + id: "tr-" + Math.random().toString(36).slice(2), + task_id: "task-1", + repository_id: "repo-x", + base_branch: "main", + position: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +describe("primaryTaskRepository", () => { + it("returns undefined for empty list", () => { + expect(primaryTaskRepository(undefined)).toBeUndefined(); + expect(primaryTaskRepository([])).toBeUndefined(); + }); + + it("picks lowest-position repo regardless of array order", () => { + const result = primaryTaskRepository([ + repo({ repository_id: "back", position: 1 }), + repo({ repository_id: "front", position: 0 }), + repo({ repository_id: "shared", position: 2 }), + ]); + expect(result?.repository_id).toBe("front"); + }); + + it("returns the only entry for a single-repo task", () => { + const result = primaryTaskRepository([repo({ repository_id: "only", position: 5 })]); + expect(result?.repository_id).toBe("only"); + }); +}); diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 3c8104114e..cb34ecfda4 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -217,12 +217,34 @@ export type TaskRepository = { task_id: string; repository_id: string; base_branch: string; + /** + * Optional branch to fetch and check out after worktree creation + * (e.g. a PR head branch). Empty when no specific branch is requested. + */ + checkout_branch?: string; position: number; metadata?: Record; created_at: string; updated_at: string; }; +/** + * Returns the primary task repository (lowest Position, first by created_at on + * tie). Returns undefined for repo-less tasks. Consumers that historically + * picked `task.repositories?.[0]` should call this to get position-aware + * selection consistent with the backend. + */ +export function primaryTaskRepository( + repos: TaskRepository[] | undefined, +): TaskRepository | undefined { + if (!repos || repos.length === 0) return undefined; + let primary = repos[0]; + for (const r of repos) { + if (r.position < primary.position) primary = r; + } + return primary; +} + export type Task = { id: string; workspace_id: string; diff --git a/apps/web/lib/ws/handlers/git-status.ts b/apps/web/lib/ws/handlers/git-status.ts index d02ef7755e..0967a8bc76 100644 --- a/apps/web/lib/ws/handlers/git-status.ts +++ b/apps/web/lib/ws/handlers/git-status.ts @@ -39,6 +39,9 @@ const gitEventHandlers: GitEventHandlers = { timestamp: event.timestamp, branch_additions: event.status.branch_additions, branch_deletions: event.status.branch_deletions, + // Multi-repo workspaces tag each status with the repository it belongs to; + // setGitStatus routes the entry into byEnvironmentRepo accordingly. + repository_name: event.status.repository_name, }); // Invalidate cumulative diff cache when files change invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id)); @@ -58,6 +61,8 @@ const gitEventHandlers: GitEventHandlers = { deletions: event.commit.deletions, committed_at: event.commit.committed_at, created_at: event.commit.created_at ?? event.timestamp, + // Multi-repo: tag the commit so the Commits panel can group per repo. + repository_name: event.commit.repository_name, }); // Invalidate cumulative diff cache when new commit is created invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id)); diff --git a/apps/web/lib/ws/workspace-files.ts b/apps/web/lib/ws/workspace-files.ts index ec1a5b12d1..0586435dea 100644 --- a/apps/web/lib/ws/workspace-files.ts +++ b/apps/web/lib/ws/workspace-files.ts @@ -22,33 +22,40 @@ export async function requestFileTree( } /** - * Request file content from the backend + * Request file content from the backend. + * `repo` is the multi-repo subpath (e.g. "kandev"); omit for single-repo + * workspaces. When set, `path` is interpreted relative to that repo. */ export async function requestFileContent( client: WebSocketClient, sessionId: string, path: string, + repo?: string, ): Promise { return client.request("workspace.file.get", { session_id: sessionId, path, + ...(repo ? { repo } : {}), }); } /** * Request file content at a specific git ref (branch, commit, HEAD, etc.) * Used for diff expansion to fetch old/base version of a file. + * `repo` scopes to a per-repo subdirectory for multi-repo workspaces. */ export async function requestFileContentAtRef( client: WebSocketClient, sessionId: string, path: string, ref: string, + repo?: string, ): Promise { return client.request("workspace.file.get_at_ref", { session_id: sessionId, path, ref, + ...(repo ? { repo } : {}), }); } @@ -80,12 +87,19 @@ export type FileUpdateResponse = { }; /** - * Update file content using a diff + * Update file content using a diff. `repo` scopes to a per-repo subdirectory + * for multi-repo workspaces; omit for single-repo. */ export async function updateFileContent( client: WebSocketClient, sessionId: string, - params: { path: string; diff: string; originalHash: string; desiredContent?: string }, + params: { + path: string; + diff: string; + originalHash: string; + desiredContent?: string; + repo?: string; + }, ): Promise { return client.request("workspace.file.update", { session_id: sessionId, @@ -95,6 +109,7 @@ export async function updateFileContent( ...(params.desiredContent !== undefined && { desired_content: params.desiredContent, }), + ...(params.repo ? { repo: params.repo } : {}), }); } @@ -108,16 +123,19 @@ export type FileCreateResponse = { }; /** - * Create a new file in the workspace + * Create a new file in the workspace. `repo` scopes the create to a per-repo + * subdirectory for multi-repo task workspaces; omit for single-repo. */ export async function createFile( client: WebSocketClient, sessionId: string, path: string, + repo?: string, ): Promise { return client.request("workspace.file.create", { session_id: sessionId, path, + ...(repo ? { repo } : {}), }); } @@ -131,16 +149,19 @@ export type FileDeleteResponse = { }; /** - * Delete a file from the workspace + * Delete a file from the workspace. `repo` scopes the delete to a per-repo + * subdirectory for multi-repo task workspaces; omit for single-repo. */ export async function deleteFile( client: WebSocketClient, sessionId: string, path: string, + repo?: string, ): Promise { return client.request("workspace.file.delete", { session_id: sessionId, path, + ...(repo ? { repo } : {}), }); } @@ -155,17 +176,21 @@ export type FileRenameResponse = { }; /** - * Rename a file or directory in the workspace + * Rename a file or directory in the workspace. `repo` scopes BOTH oldPath and + * newPath to a per-repo subdirectory for multi-repo task workspaces; omit + * for single-repo. Cross-repo moves aren't supported. */ export async function renameFile( client: WebSocketClient, sessionId: string, oldPath: string, newPath: string, + repo?: string, ): Promise { return client.request("workspace.file.rename", { session_id: sessionId, old_path: oldPath, new_path: newPath, + ...(repo ? { repo } : {}), }); }