diff --git a/.gitignore b/.gitignore index 52886724..9ed13b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ docs/* !docs/architecture-overview.html !docs/document-workspaces.md !docs/browser-preview.md +!docs/pooled-workspaces.md # Sandbox bind-mount artifacts from user home (not project files). # Root-anchored so legitimate nested files with these names are still tracked. diff --git a/AGENTS.md b/AGENTS.md index fcb3ec45..db252494 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,4 +55,5 @@ When committing, use conventional commit messages, such as `fix(terminal): resto - `src/remote/`, `electron/remote/`: phone UI and its server. - `src/documents/`, `electron/documents/`: document workspaces; see `docs/document-workspaces.md`. - `docs/browser-preview.md`: native browser architecture, limitations, and verification. +- `docs/pooled-workspaces.md`: leasing a ready environment instead of building a worktree, for projects that are a directory of git repositories. - `docs/architecture-overview.html`: broader architecture overview. New files under `docs/` are ignored unless explicitly included in `.gitignore`. diff --git a/docs/pooled-workspaces.md b/docs/pooled-workspaces.md new file mode 100644 index 00000000..17669427 --- /dev/null +++ b/docs/pooled-workspaces.md @@ -0,0 +1,178 @@ +# Pooled workspaces + +A pooled workspace is a project whose environments already exist. Instead of building a git worktree +per task, the app **leases** one of a fixed set of ready checkouts, branches every repository inside +it, and gives it back when the task closes. + +Use it when a project is not one git repository but a directory holding several side by side, and +when getting one of those directories ready is expensive enough that copying it per task is not +worth it — installed dependencies, initialised submodules, warm build caches. + +## When a worktree is the wrong shape + +`git worktree` works on one repository. Three things break when a project is a directory of them: + +- **A worktree of the parent does not bring the children.** The children are their own + repositories; a worktree of the container is an empty container. +- **`git worktree add` does not populate submodules.** A repository whose real code sits in + `packages/*` submodules produces a checkout that cannot build. +- **One task, several branches.** A change that spans three repositories wants the same branch name + in each, and a `Task` holds one. + +Leasing sidesteps all three by never building an environment in the first place. + +## Configuring one + +Project settings → **Pooled environments**. One absolute path per line: + +``` +/projects/MRW1 +/projects/MRW2 +/projects/MRW3 +``` + +Clearing the field makes the project ordinary again — there is no separate switch. + +- **Repositories** — blank means "whatever the environment holds". Discovery prefers a manifest at + the environment root (`repos.tsv`, `nameurl[branch]`) because it names repositories that + belong to the workspace even while they are missing; otherwise it scans one level down for git + checkouts. Listing repositories here overrides both. + + The environment's own repository is included as `.` whenever the root is a git checkout, so a + change can touch the manifest, a shared script or the instructions that live there. A manifest + never lists it — it is the repository the manifest lives in. An explicit list names the full set, + so there it participates only if you write `.` in it. + + A repository the manifest declares but that nobody cloned is reported, not refused. Manifests + drift from the checkouts beside them: the Winston dev-env's `repos.tsv` names three repositories + that are not cloned and omits three that are, and blocking on that would make the pool unusable. + Where the manifest and the checkouts disagree this much, list the repositories explicitly. + +- **Port base per environment** and **port offset per repository** — see [Ports](#ports). + +Then start a task with **Git Isolation → Pooled Env**. The panel says how many environments are free +before you create it. + +## What a task does + +1. **Leases** the first environment with no live lease. A lease is held in app state and written to + `/.parallel-code/lease.json`, so a second app instance — or a person in a terminal — can see + the environment is taken. A lease whose task no longer exists is reclaimed. +2. **Checks it is ready.** Every member repository present must be clean and on a branch. All + blockers are reported at once, named per repository, rather than one per attempt. A declared but + uncloned repository is reported without blocking. +3. **Branches** every member repository to the same name. Creating a branch is free when the + repository is already at base, an unused one is deleted on release, and doing it up front means + the agent never has to stop and ask before editing a second repository. Creation is + all-or-nothing: if one repository refuses, the others are rolled back and the lease freed. +4. **Runs** with the environment root as the working directory, so shells, the canvas, the browser + preview and the verify command all point at it exactly as they point at a worktree. +5. **Releases** on close: every repository returns to its base branch, submodules are restored to + that branch's pins, unused task branches are deleted, and the lease is dropped. A branch that + still holds commits is kept unless the project deletes branches on close. + +## The change, across repositories + +Changed files and the diff are the union of the member repositories, with each path re-rooted under +its repository name — except the environment's own repository, whose files are at the root already +and so are left alone. That prefix is what makes the existing panels work unchanged: `waiter/src/a.ts` +is a real path relative to the environment root, which is the path those panels already hold, so +opening a file in an editor and routing a per-file diff back to its repository both fall out of it. + +Merge and push fan out the same way, sequentially, skipping repositories with no commits. Push +labels each repository's output; merge stops at the first conflict, because half a change on base is +worse than none. + +**Local merging is not how every such workspace integrates.** Where repositories carry submodules, +the convention is a pull request per repository, children merged first, so the parent never points +at an unmerged commit. Push the branches and open the pull requests in that case; the merge button +is for workspaces that do integrate locally. + +## Shared libraries that exist twice + +Some workspaces check a shared library out both as a sibling repository at the environment root and +as a submodule inside each application that uses it. The applications build against their submodule +copy, so that is where a change has to be made for the running application to pick it up — but the +copies are pinned independently and drift, so it is not where the change should be committed. In the +Winston dev-env the four applications pin four different commits of `shared`, none of them the +sibling checkout's `dev` tip. + +The sibling checkout is treated as canonical, and the flow is: + +1. **On lease**, every copy is put on its canonical repository's base branch, fetched from the + canonical checkout on disk rather than over the network. All copies and the canonical checkout + then share one base. The copy stays detached: it is a build input for the task, not where commits + belong. +2. **During the task**, the agent edits the copy, and the running application picks the change up. +3. **Before committing**, **Sync shared** in the task's title bar carries each copy's changes into + the canonical checkout — commits made inside the copy as well as uncommitted work — as a patch + taken against that shared base, so it applies by construction. The result is left uncommitted: + the message is the author's. +4. **Pushing refuses** while a copy still holds changes the canonical checkout has not taken, since + that would send the application branch without the shared change it was written against. +5. **On release**, `git submodule update --force` puts every copy back on its recorded pin. + +A copy is recognised by name: `packages/shared` inside `waiter` mirrors the member repo called +`shared`. A submodule with no member of that name — a vendored dependency, a skills checkout — is +left alone. + +The consequence of step 1 is worth stating plainly: an application runs against the shared library's +base branch rather than the commit it pins. That is the point — the change is authored, run and +committed against one base — but it does mean a library that has moved ahead incompatibly will show +up as a broken application rather than as a merge conflict later. + +## Ports + +Two tasks in a pool run two copies of the same applications, so they cannot share an application's +default port. The port belongs to the environment, not to the task — a lease comes and goes, but +someone who learns that `MRW2` serves on 3511 should keep being right. + +``` +Port base per environment Port offset per repository +/projects/MRW1 = 3500 waiter = 1 +/projects/MRW2 = 3510 backoffice = 2 +``` + +A task's terminals then get `PARALLEL_CODE_PORT_WAITER=3511`, `PARALLEL_CODE_PORT_BACKOFFICE=3512`, +`PARALLEL_CODE_ENV_PATH`, and `PORT` pointing at the lowest of them for the common case of a start +script that reads `PORT`. A project that configures no ports is handed nothing rather than a +misleading `PORT`. + +## Limits + +- **Concurrency is the pool size.** When every environment is leased, creating a task fails and says + which task holds each one. Environments are not built on demand. +- **Commit navigation is off.** It is per-repository, and a pool task spans several. +- **Submodules are not branched.** A submodule the environment keeps no canonical copy of — a + vendored dependency — still needs its own branch and pull request by hand. A shared library the + environment does keep a copy of is handled above. +- **The parent's submodule pointer is not bumped.** The shared change lands on the canonical + repository's own branch; re-pinning each application after that branch merges is still manual, + which is also what keeps a parent from ever pointing at an unmerged commit. +- **An environment must be given back clean.** The readiness check refuses a dirty repository, which + is also what stops a task inheriting the previous one's leftovers. + +## Where the code is + +| Concern | File | +| ------------------------------ | ------------------------------ | +| Manifest parsing and discovery | `electron/ipc/pool-members.ts` | +| Leasing, readiness, release | `electron/ipc/pool.ts` | +| Changed files, diffs, status | `electron/ipc/pool-git.ts` | +| Shared-library copies | `electron/ipc/pool-shared.ts` | +| Task create and close | `electron/ipc/tasks.ts` | +| Port variables | `src/lib/pool-ports.ts` | +| Project settings form | `src/lib/pool-config.ts` | + +## Verification + +```sh +npx vitest run electron/ipc/pool.test.ts electron/ipc/pool-members.test.ts electron/ipc/pool-git.test.ts electron/ipc/pool-shared.test.ts +npx vitest run src/lib/pool-ports.test.ts src/lib/pool-config.test.ts +``` + +The lease, readiness and release tests run against real git repositories in a temporary directory. +What they cannot cover is an environment with submodules, installed dependencies and a real +application in it: lease one, edit a file in two of its repositories, check the changed-file list +shows both under their repository names, then close the task and confirm every repository is back on +its base branch with its submodule pins restored. diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index 1a4cdca2..26edf94d 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -10,6 +10,15 @@ "ListAgents": "list_agents", "CreateTask": "create_task", "DeleteTask": "delete_task", + "PoolEnvStatus": "pool_env_status", + "PoolAcquireEnv": "pool_acquire_env", + "PoolReleaseEnv": "pool_release_env", + "PoolChangedFiles": "pool_changed_files", + "PoolFileDiff": "pool_file_diff", + "PoolAllDiffs": "pool_all_diffs", + "PoolStatus": "pool_status", + "PoolSharedPending": "pool_shared_pending", + "PoolSharedAggregate": "pool_shared_aggregate", "GetChangedFiles": "get_changed_files", "GetChangedFilesFromBranch": "get_changed_files_from_branch", "GetAllFileDiffs": "get_all_file_diffs", diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index 8b5531f5..3ce57f8c 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -202,6 +202,17 @@ const SYMLINK_EXCLUDE_HEADER = '# parallel-code: worktree symlinks'; * `..` is only rejected as a full name: as a substring (`foo..bar`) it is a * legal filename, not a traversal. */ +/** + * Run a git command in `cwd` and return its trimmed stdout. + * + * Exported so the pool module drives git through the same traced, buffered + * path as everything else here rather than reaching for `execFile` itself. + */ +export async function runGit(cwd: string, args: string[]): Promise { + const { stdout } = await exec('git', args, { cwd, maxBuffer: MAX_BUFFER }); + return stdout.trim(); +} + export function isValidSymlinkName(name: string): boolean { if (name.length === 0 || name === '.' || name === '..') return false; if (name.includes('/') || name.includes('\\')) return false; @@ -2097,6 +2108,9 @@ export function pushTask( projectRoot: string, branchName: string, channelId: string, + /** Written to the stream before git runs, so a caller pushing several + * repositories in turn can say which one each block of output is from. */ + label?: string, ): Promise { return new Promise((resolve, reject) => { const proc = spawn('git', ['push', '--progress', '-u', 'origin', '--', branchName], { @@ -2110,6 +2124,8 @@ export function pushTask( } }; + if (label) send(`\n=== ${label} ===\n`); + proc.stdout?.on('data', (chunk: Buffer) => { send(chunk.toString('utf8')); }); diff --git a/electron/ipc/pool-git.test.ts b/electron/ipc/pool-git.test.ts new file mode 100644 index 00000000..cca78e3e --- /dev/null +++ b/electron/ipc/pool-git.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + poolAllDiffs, + poolChangedFiles, + poolFileDiff, + poolStatus, + prefixChangedFiles, + prefixDiffPaths, + splitRepoPath, +} from './pool-git.js'; +import type { PoolRepo } from './pool.js'; +import type { ChangedFile } from './shared-types.js'; + +const repos: PoolRepo[] = [ + { name: 'waiter', path: '/envs/MRW1/waiter', branchName: 'task/win-1', baseBranch: 'dev' }, + { name: 'shared', path: '/envs/MRW1/shared', branchName: 'task/win-1', baseBranch: 'dev' }, +]; + +describe('splitRepoPath', () => { + it('routes a path to the repository that owns it', () => { + expect(splitRepoPath(repos, 'shared/client/index.ts')).toEqual({ + repo: repos[1], + filePath: 'client/index.ts', + }); + }); + + it('returns null for a path outside every member repo', () => { + expect(splitRepoPath(repos, 'repos.tsv')).toBeNull(); + expect(splitRepoPath(repos, 'waiterish/file.ts')).toBeNull(); + }); + + it('falls back to the environment’s own repository for an unprefixed path', () => { + const root = { name: '.', path: '/envs/MRW1', branchName: 'task/win-1', baseBranch: 'dev' }; + const withRoot = [...repos, root]; + expect(splitRepoPath(withRoot, 'repos.tsv')).toEqual({ repo: root, filePath: 'repos.tsv' }); + // A child repo's file still routes to the child, not to the fallback. + expect(splitRepoPath(withRoot, 'waiter/src/a.ts')).toEqual({ + repo: repos[0], + filePath: 'src/a.ts', + }); + }); +}); + +describe('prefixChangedFiles', () => { + it('re-roots paths, including a rename’s previous path', () => { + const files: ChangedFile[] = [ + { + path: 'src/new.ts', + previous_path: 'src/old.ts', + lines_added: 1, + lines_removed: 1, + status: 'R', + committed: true, + }, + ]; + expect(prefixChangedFiles('waiter', files)[0]).toMatchObject({ + path: 'waiter/src/new.ts', + previous_path: 'waiter/src/old.ts', + }); + }); +}); + +describe('the environment’s own repository', () => { + it('keeps its paths as they are, in file lists and in diffs', () => { + const files: ChangedFile[] = [ + { path: 'repos.tsv', lines_added: 1, lines_removed: 0, status: 'M', committed: false }, + ]; + expect(prefixChangedFiles('.', files)).toEqual(files); + const diff = '--- a/repos.tsv\n+++ b/repos.tsv'; + expect(prefixDiffPaths('.', diff)).toBe(diff); + }); +}); + +describe('prefixDiffPaths', () => { + it('re-roots the path-bearing headers only', () => { + const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + 'index 111..222 100644', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ].join('\n'); + expect(prefixDiffPaths('waiter', diff).split('\n')).toEqual([ + 'diff --git a/waiter/src/a.ts b/waiter/src/a.ts', + 'index 111..222 100644', + '--- a/waiter/src/a.ts', + '+++ b/waiter/src/a.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ]); + }); + + it('re-roots a rename and leaves diff-shaped body lines alone', () => { + const diff = ['rename from src/old.ts', 'rename to src/new.ts', '+--- a/not/a/header'].join( + '\n', + ); + expect(prefixDiffPaths('shared', diff).split('\n')).toEqual([ + 'rename from shared/src/old.ts', + 'rename to shared/src/new.ts', + '+--- a/not/a/header', + ]); + }); + + it('leaves /dev/null in place, so an added file still parses', () => { + const diff = ['--- /dev/null', '+++ b/src/new.ts'].join('\n'); + expect(prefixDiffPaths('waiter', diff).split('\n')).toEqual([ + '--- /dev/null', + '+++ b/waiter/src/new.ts', + ]); + }); +}); + +describe('against real repositories', () => { + let root: string; + let envRepos: PoolRepo[]; + + function git(cwd: string, ...args: string[]): void { + execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + }, + }); + } + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pool-git-')); + envRepos = ['waiter', 'shared'].map((name) => { + const repoPath = path.join(root, name); + fs.mkdirSync(repoPath); + git(repoPath, 'init', '--initial-branch=dev', '--quiet'); + fs.writeFileSync(path.join(repoPath, 'index.ts'), 'export const a = 1;\n'); + git(repoPath, 'add', '.'); + git(repoPath, 'commit', '--quiet', '-m', 'initial'); + git(repoPath, 'checkout', '--quiet', '-b', 'task/win-1'); + return { name, path: repoPath, branchName: 'task/win-1', baseBranch: 'dev' }; + }); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('lists changed files from every repo under its own name', async () => { + fs.writeFileSync(path.join(root, 'waiter', 'index.ts'), 'export const a = 2;\n'); + fs.writeFileSync(path.join(root, 'shared', 'added.ts'), 'export const b = 3;\n'); + + const files = await poolChangedFiles(envRepos); + expect(files.map((file) => file.path).sort()).toEqual(['shared/added.ts', 'waiter/index.ts']); + }); + + it('diffs a file in the repo that owns it', async () => { + fs.writeFileSync(path.join(root, 'shared', 'index.ts'), 'export const a = 99;\n'); + const diff = await poolFileDiff(envRepos, 'shared/index.ts'); + expect(diff.newContent).toContain('99'); + }); + + it('concatenates every repo’s diff with paths re-rooted at the environment', async () => { + fs.writeFileSync(path.join(root, 'waiter', 'index.ts'), 'export const a = 2;\n'); + fs.writeFileSync(path.join(root, 'shared', 'index.ts'), 'export const a = 3;\n'); + + const diff = await poolAllDiffs(envRepos); + expect(diff).toContain('a/waiter/index.ts'); + expect(diff).toContain('a/shared/index.ts'); + }); + + it('refuses a path no member repo owns', async () => { + await expect(poolFileDiff(envRepos, 'repos.tsv')).rejects.toThrow(/No pool repository owns/); + }); + + it('rolls per-repo status up, and reports a mixed branch as none', async () => { + fs.writeFileSync(path.join(root, 'waiter', 'index.ts'), 'export const a = 2;\n'); + + const agreed = await poolStatus(envRepos); + expect(agreed.combined.has_uncommitted_changes).toBe(true); + expect(agreed.combined.current_branch).toBe('task/win-1'); + expect(agreed.perRepo.map((status) => status.repo)).toEqual(['waiter', 'shared']); + + git(path.join(root, 'shared'), 'checkout', '--quiet', 'dev'); + const mixed = await poolStatus(envRepos); + expect(mixed.combined.current_branch).toBeNull(); + }); +}); diff --git a/electron/ipc/pool-git.ts b/electron/ipc/pool-git.ts new file mode 100644 index 00000000..07536ac6 --- /dev/null +++ b/electron/ipc/pool-git.ts @@ -0,0 +1,165 @@ +import path from 'path'; + +import { getAllFileDiffs, getChangedFiles, getFileDiff, getWorktreeStatus } from './git.js'; +import type { PoolRepo } from './pool.js'; +import { ROOT_MEMBER } from './pool-members.js'; +import type { ChangedFile, FileDiffResult, WorktreeStatus } from './shared-types.js'; + +/** + * The git surface of a pool task. + * + * A pool task spans every member repository of its leased environment, so + * "the changed files" is the union of several repositories rather than one + * repository's answer. Aggregation happens here rather than in the renderer + * because the existing panels are built around a single path, and the one + * thing that makes them work unchanged is that a member repository sits at a + * fixed subdirectory of the environment: prefix each path with its repository + * name and the result is a real path relative to the environment root, which + * is exactly what those panels already hold. + * + * The same prefix read backwards routes a per-file request to the repository + * that owns it. + */ + +/** + * Split an environment-relative path into its member repo and the rest. + * + * The environment's own repository carries no prefix — its files are already + * at the root — so it is the fallback rather than a match, and a path that + * belongs to a child repository is never mistaken for one of its files. + */ +export function splitRepoPath( + repos: PoolRepo[], + envRelativePath: string, +): { repo: PoolRepo; filePath: string } | null { + const normalized = envRelativePath.split(path.sep).join('/'); + for (const repo of repos) { + if (repo.name === ROOT_MEMBER) continue; + const prefix = `${repo.name}/`; + if (normalized.startsWith(prefix)) { + return { repo, filePath: normalized.slice(prefix.length) }; + } + } + const root = repos.find((repo) => repo.name === ROOT_MEMBER); + return root ? { repo: root, filePath: normalized } : null; +} + +/** Re-root one repository's changed files at the environment. */ +export function prefixChangedFiles(repoName: string, files: ChangedFile[]): ChangedFile[] { + if (repoName === ROOT_MEMBER) return files; // already environment-relative + return files.map((file) => ({ + ...file, + path: `${repoName}/${file.path}`, + previous_path: file.previous_path ? `${repoName}/${file.previous_path}` : undefined, + })); +} + +/** + * Every member repository's changed files, as one list. + * + * A repository that cannot be read contributes nothing rather than failing the + * call: a task spanning five repositories should still show the four that + * answered, and the environment status is where an unreadable repository is + * meant to surface. + */ +export async function poolChangedFiles(repos: PoolRepo[]): Promise { + const perRepo = await Promise.all( + repos.map(async (repo) => { + try { + return prefixChangedFiles(repo.name, await getChangedFiles(repo.path, repo.baseBranch)); + } catch { + return []; + } + }), + ); + return perRepo.flat(); +} + +/** + * Re-root the paths in one repository's unified diff at the environment. + * + * Only the path-bearing headers are rewritten, and each is anchored to the + * start of its line, so a `+++ b/x` that appears inside an added line of the + * body — a diff of a diff, or of a patch fixture — is left alone. + */ +export function prefixDiffPaths(repoName: string, diff: string): string { + if (repoName === ROOT_MEMBER) return diff; // already environment-relative + return diff + .split('\n') + .map((line) => { + if (line.startsWith('diff --git ')) { + return line.replace(/ a\/(.*) b\/(.*)$/, ` a/${repoName}/$1 b/${repoName}/$2`); + } + if (line.startsWith('--- a/')) return `--- a/${repoName}/${line.slice(6)}`; + if (line.startsWith('+++ b/')) return `+++ b/${repoName}/${line.slice(6)}`; + if (line.startsWith('rename from ')) return `rename from ${repoName}/${line.slice(12)}`; + if (line.startsWith('rename to ')) return `rename to ${repoName}/${line.slice(10)}`; + return line; + }) + .join('\n'); +} + +/** + * Every member repository's diff, concatenated, with its paths re-rooted at + * the environment so the viewer shows one change spanning several + * repositories rather than several unrelated ones. + */ +export async function poolAllDiffs(repos: PoolRepo[]): Promise { + const perRepo = await Promise.all( + repos.map(async (repo) => { + try { + const diff = await getAllFileDiffs(repo.path, repo.baseBranch); + return diff.trim() ? prefixDiffPaths(repo.name, diff) : ''; + } catch { + return ''; + } + }), + ); + return perRepo.filter(Boolean).join('\n'); +} + +/** The diff of one file, routed to the member repository that owns it. */ +export async function poolFileDiff( + repos: PoolRepo[], + envRelativePath: string, +): Promise { + const owner = splitRepoPath(repos, envRelativePath); + if (!owner) throw new Error(`No pool repository owns "${envRelativePath}"`); + return getFileDiff(owner.repo.path, owner.filePath, owner.repo.baseBranch); +} + +export interface PoolRepoStatus extends WorktreeStatus { + repo: string; +} + +/** + * Per-repository status plus the rolled-up answer the task header shows. + * + * The roll-up is a disjunction — any repository with changes makes the task + * changed — and the branch is reported only when every repository agrees on + * it, so a member left behind on its base branch is visible as "mixed" rather + * than hidden behind the majority. + */ +export async function poolStatus(repos: PoolRepo[]): Promise<{ + perRepo: PoolRepoStatus[]; + combined: WorktreeStatus; +}> { + const perRepo = await Promise.all( + repos.map(async (repo) => ({ + repo: repo.name, + ...(await getWorktreeStatus(repo.path, repo.baseBranch)), + })), + ); + + const branches = new Set(perRepo.map((status) => status.current_branch)); + const baseBranches = new Set(perRepo.map((status) => status.base_branch)); + return { + perRepo, + combined: { + has_committed_changes: perRepo.some((status) => status.has_committed_changes), + has_uncommitted_changes: perRepo.some((status) => status.has_uncommitted_changes), + current_branch: branches.size === 1 ? (perRepo[0]?.current_branch ?? null) : null, + base_branch: baseBranches.size === 1 ? (perRepo[0]?.base_branch ?? null) : null, + }, + }; +} diff --git a/electron/ipc/pool-members.test.ts b/electron/ipc/pool-members.test.ts new file mode 100644 index 00000000..da8e1687 --- /dev/null +++ b/electron/ipc/pool-members.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + ROOT_MEMBER, + discoverMembers, + isSafeMemberName, + parseRepoManifest, + readManifest, + scanForMembers, +} from './pool-members.js'; + +let envPath: string; + +beforeEach(() => { + envPath = fs.mkdtempSync(path.join(os.tmpdir(), 'pool-members-')); +}); + +afterEach(() => { + fs.rmSync(envPath, { recursive: true, force: true }); +}); + +/** A checked-out member: a directory with a `.git` entry in it. */ +function makeRepo(name: string, gitEntry: 'dir' | 'file' = 'dir'): void { + const repo = path.join(envPath, name); + fs.mkdirSync(repo, { recursive: true }); + if (gitEntry === 'dir') fs.mkdirSync(path.join(repo, '.git')); + else fs.writeFileSync(path.join(repo, '.git'), 'gitdir: ../.git/modules/x'); +} + +describe('isSafeMemberName', () => { + it('accepts an ordinary directory name', () => { + expect(isSafeMemberName('waiter')).toBe(true); + expect(isSafeMemberName('report-service')).toBe(true); + }); + + it('rejects anything that would leave the environment root', () => { + expect(isSafeMemberName('..')).toBe(false); + expect(isSafeMemberName('../etc')).toBe(false); + expect(isSafeMemberName('a/b')).toBe(false); + expect(isSafeMemberName('a\\b')).toBe(false); + }); + + it('rejects dotted, empty, newline and untrimmed names', () => { + expect(isSafeMemberName('.git')).toBe(false); + expect(isSafeMemberName('.worktrees')).toBe(false); + expect(isSafeMemberName('')).toBe(false); + expect(isSafeMemberName('waiter\nrm -rf')).toBe(false); + expect(isSafeMemberName(' waiter')).toBe(false); + }); +}); + +describe('parseRepoManifest', () => { + it('reads name, skips the url, and keeps a branch override', () => { + const members = parseRepoManifest( + ['api\tgit@bitbucket.org:possys/api.git', 'reports\tgit@host:possys/reports.git\tmain'].join( + '\n', + ), + ); + expect(members).toEqual([ + { name: 'api', branch: undefined }, + { name: 'reports', branch: 'main' }, + ]); + }); + + it('splits on any whitespace, not tabs alone', () => { + // The Winston dev-env's own repos.tsv has a space-separated row. + expect(parseRepoManifest('report-service git@host:possys/report-service.git')).toEqual([ + { name: 'report-service', branch: undefined }, + ]); + }); + + it('skips comments and blank lines', () => { + expect(parseRepoManifest('# a comment\n\n \napi\tgit@host:api.git')).toEqual([ + { name: 'api', branch: undefined }, + ]); + }); + + it('keeps the first of a duplicated name', () => { + const members = parseRepoManifest( + 'report-service\tgit@host:a.git\treports\nreport-service git@host:b.git', + ); + expect(members).toEqual([{ name: 'report-service', branch: 'reports' }]); + }); + + it('drops an unsafe row without losing the file', () => { + const members = parseRepoManifest('../escape\tgit@host:a.git\napi\tgit@host:api.git'); + expect(members).toEqual([{ name: 'api', branch: undefined }]); + }); +}); + +describe('readManifest', () => { + it('returns null when the environment has no manifest', () => { + expect(readManifest(envPath)).toBeNull(); + }); + + it('reads repos.tsv from the environment root', () => { + fs.writeFileSync(path.join(envPath, 'repos.tsv'), 'waiter\tgit@host:waiter.git\n'); + expect(readManifest(envPath)).toEqual([{ name: 'waiter', branch: undefined }]); + }); +}); + +describe('scanForMembers', () => { + it('finds child git repositories and ignores everything else', () => { + makeRepo('waiter'); + makeRepo('shared', 'file'); + fs.mkdirSync(path.join(envPath, 'scripts')); + fs.mkdirSync(path.join(envPath, '.worktrees')); + fs.writeFileSync(path.join(envPath, 'README.md'), ''); + expect(scanForMembers(envPath)).toEqual([{ name: 'shared' }, { name: 'waiter' }]); + }); + + it('returns nothing for an unreadable environment', () => { + expect(scanForMembers(path.join(envPath, 'does-not-exist'))).toEqual([]); + }); +}); + +describe('discoverMembers', () => { + it('prefers the manifest and reports declared repos that are not cloned yet', () => { + fs.writeFileSync( + path.join(envPath, 'repos.tsv'), + 'waiter\tgit@host:waiter.git\napi\tgit@host:api.git\n', + ); + makeRepo('waiter'); + expect(discoverMembers(envPath)).toEqual({ + members: [{ name: 'waiter', branch: undefined }], + missing: ['api'], + }); + }); + + it('falls back to a scan when there is no manifest', () => { + makeRepo('waiter'); + expect(discoverMembers(envPath)).toEqual({ members: [{ name: 'waiter' }], missing: [] }); + }); + + it("uses the project's own list ahead of the manifest", () => { + fs.writeFileSync(path.join(envPath, 'repos.tsv'), 'waiter\tgit@host:waiter.git\n'); + makeRepo('waiter'); + makeRepo('api'); + expect(discoverMembers(envPath, ['api'])).toEqual({ members: [{ name: 'api' }], missing: [] }); + }); + + it('includes the environment’s own repository when the root is a checkout', () => { + fs.mkdirSync(path.join(envPath, '.git')); + fs.writeFileSync(path.join(envPath, 'repos.tsv'), 'waiter\tgit@host:waiter.git\n'); + makeRepo('waiter'); + expect(discoverMembers(envPath)).toEqual({ + members: [{ name: ROOT_MEMBER }, { name: 'waiter', branch: undefined }], + missing: [], + }); + }); + + it('leaves the root out when the environment root is not a repository', () => { + makeRepo('waiter'); + expect(discoverMembers(envPath).members).toEqual([{ name: 'waiter' }]); + }); + + it('leaves the root out of an explicit list that does not name it', () => { + fs.mkdirSync(path.join(envPath, '.git')); + makeRepo('waiter'); + expect(discoverMembers(envPath, ['waiter']).members).toEqual([{ name: 'waiter' }]); + expect(discoverMembers(envPath, [ROOT_MEMBER, 'waiter']).members).toEqual([ + { name: ROOT_MEMBER }, + { name: 'waiter' }, + ]); + }); + + it('drops an unsafe configured name instead of building a path from it', () => { + makeRepo('api'); + expect(discoverMembers(envPath, ['../etc', 'api'])).toEqual({ + members: [{ name: 'api' }], + missing: [], + }); + }); +}); diff --git a/electron/ipc/pool-members.ts b/electron/ipc/pool-members.ts new file mode 100644 index 00000000..8d59c9b3 --- /dev/null +++ b/electron/ipc/pool-members.ts @@ -0,0 +1,155 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Member discovery for pooled workspaces. + * + * A pooled environment is a directory holding several independent git + * repositories side by side. Which repositories those are is either stated by + * a manifest the workspace already keeps for its own setup script, or — when + * there is none — discovered by looking one level down for git repositories. + * + * Nothing here runs git or touches the environment beyond reading it, so the + * parsing rules stay unit-testable on fixtures. + */ + +/** Manifest filenames probed at an environment root, in order. */ +export const MANIFEST_FILENAMES = ['repos.tsv'] as const; + +/** + * The environment's own repository, when the root is itself a checkout. + * + * It is named `.` because that is what its path is relative to the + * environment, and because everything that re-roots a path under a member + * name has to leave this one alone: its files already sit at the root. + */ +export const ROOT_MEMBER = '.'; + +export interface PoolMemberSpec { + /** Directory name under the environment root. */ + name: string; + /** Branch the manifest pins this repository to, when it names one. */ + branch?: string; +} + +/** + * A member name becomes a path segment under the environment root, so it is + * validated rather than trusted: a manifest is a file inside a repository and + * a checkout is not a trust boundary. Leading dots are rejected too, which + * keeps `.git`, `.worktrees` and the pool's own `.parallel-code` out of the + * member list whichever way they were proposed. + */ +export function isSafeMemberName(name: string): boolean { + if (name.length === 0 || name.startsWith('.')) return false; + if (name.includes('/') || name.includes('\\')) return false; + if (name.includes('\n') || name.includes('\r')) return false; + if (name !== name.trim()) return false; + return true; +} + +/** + * Parse a `nameurl[branch]` manifest. + * + * Fields are split on any run of whitespace rather than on tabs alone: the + * Winston dev-env's own `repos.tsv` has a space-separated row, and a manifest + * that a human maintains will keep acquiring them. Blank lines and `#` + * comments are skipped, the first entry wins on a duplicate name, and a row + * whose name would not be a safe path segment is dropped rather than failing + * the whole file — one bad row should not cost the other eleven. + */ +export function parseRepoManifest(text: string): PoolMemberSpec[] { + const seen = new Set(); + const members: PoolMemberSpec[] = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const [name, , branch] = line.split(/\s+/); + if (!name || !isSafeMemberName(name) || seen.has(name)) continue; + seen.add(name); + members.push({ name, branch: branch || undefined }); + } + return members; +} + +/** Read the first manifest present at an environment root, if any. */ +export function readManifest(envPath: string): PoolMemberSpec[] | null { + for (const filename of MANIFEST_FILENAMES) { + try { + const text = fs.readFileSync(path.join(envPath, filename), 'utf8'); + return parseRepoManifest(text); + } catch { + // Absent or unreadable — try the next name, then fall back to a scan. + } + } + return null; +} + +/** Whether `dir` is a git repository: `.git` as a directory or a worktree file. */ +export function isGitCheckout(dir: string): boolean { + try { + return fs.existsSync(path.join(dir, '.git')); + } catch { + return false; + } +} + +/** Directories one level under `envPath` that are git repositories. */ +export function scanForMembers(envPath: string): PoolMemberSpec[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(envPath, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory() && isSafeMemberName(entry.name)) + .filter((entry) => isGitCheckout(path.join(envPath, entry.name))) + .map((entry) => ({ name: entry.name })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * The member repositories of one environment. + * + * `configured` is the project's own list and wins outright when set, because a + * user who narrowed the list meant it. Otherwise a manifest is preferred over a + * scan, since it names repositories that belong to the workspace even while + * they are missing — a distinction the readiness check needs and a scan of the + * filesystem cannot make. + * + * Entries are filtered to what is actually checked out; `missing` carries the + * rest, so "you have not run the setup script yet" stays a different answer + * from "this environment has no repositories". + * + * The environment root itself is included when it is a git checkout, since a + * workspace that is a repository of repositories still has files of its own. + */ +export function discoverMembers( + envPath: string, + configured?: string[], +): { members: PoolMemberSpec[]; missing: string[] } { + const declared = configured?.length + ? configured + .filter((name) => name === ROOT_MEMBER || isSafeMemberName(name)) + .map((name) => ({ + name, + })) + : (readManifest(envPath) ?? scanForMembers(envPath)); + const members: PoolMemberSpec[] = []; + const missing: string[] = []; + + // The workspace's own repository participates like any other: a change can + // touch the manifest, a shared script or the instructions at the root. A + // manifest never lists it — it is the repository the manifest lives in — so + // it is added here rather than declared. An explicit member list names the + // full set, so there it is included only when it says so. + const includesRoot = configured?.length ? configured.includes(ROOT_MEMBER) : true; + if (includesRoot && isGitCheckout(envPath)) members.push({ name: ROOT_MEMBER }); + + for (const spec of declared) { + if (spec.name === ROOT_MEMBER) continue; // already handled above + if (isGitCheckout(path.join(envPath, spec.name))) members.push(spec); + else missing.push(spec.name); + } + return { members, missing }; +} diff --git a/electron/ipc/pool-shared.test.ts b/electron/ipc/pool-shared.test.ts new file mode 100644 index 00000000..cb4a18af --- /dev/null +++ b/electron/ipc/pool-shared.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + aggregateMirrors, + alignMirrors, + findSharedMirrors, + submodulePaths, + unaggregatedMirrors, +} from './pool-shared.js'; +import type { PoolRepo } from './pool.js'; + +let root: string; +let repos: PoolRepo[]; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + // Local paths as submodule sources are refused by default since CVE-2022-39253. + GIT_ALLOW_PROTOCOL: 'file', + }, + }).trim(); +} + +function repo(name: string): PoolRepo { + return { + name, + path: path.join(root, name), + branchName: 'task/win-1', + baseBranch: 'dev', + }; +} + +/** + * An environment shaped like the real thing: a canonical `shared` repository at + * the root, and a `waiter` application carrying its own submodule copy of it + * pinned to an *older* commit — the pin gap that alignment exists to close. + */ +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pool-shared-')); + + const shared = path.join(root, 'shared'); + fs.mkdirSync(shared); + git(shared, 'init', '--initial-branch=dev', '--quiet'); + fs.writeFileSync(path.join(shared, 'index.ts'), 'export const version = 1;\n'); + git(shared, 'add', '.'); + git(shared, 'commit', '--quiet', '-m', 'v1'); + const oldPin = git(shared, 'rev-parse', 'HEAD'); + fs.writeFileSync(path.join(shared, 'index.ts'), 'export const version = 2;\n'); + git(shared, 'add', '.'); + git(shared, 'commit', '--quiet', '-m', 'v2'); + + const waiter = path.join(root, 'waiter'); + fs.mkdirSync(waiter); + git(waiter, 'init', '--initial-branch=dev', '--quiet'); + fs.writeFileSync(path.join(waiter, 'app.ts'), 'export const app = true;\n'); + git(waiter, 'add', '.'); + git(waiter, 'commit', '--quiet', '-m', 'initial'); + git( + waiter, + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + '--quiet', + shared, + 'packages/shared', + ); + git(path.join(waiter, 'packages/shared'), 'checkout', '--quiet', '--detach', oldPin); + git(waiter, 'add', '.'); + git(waiter, 'commit', '--quiet', '-m', 'pin shared to v1'); + + // Both repos on the task branch, as a lease would leave them. + for (const name of ['shared', 'waiter']) + git(path.join(root, name), 'checkout', '--quiet', '-b', 'task/win-1'); + repos = [repo('shared'), repo('waiter')]; +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('submodulePaths', () => { + it('reads the declared paths, and nothing for a repo without submodules', async () => { + await expect(submodulePaths(path.join(root, 'waiter'))).resolves.toEqual(['packages/shared']); + await expect(submodulePaths(path.join(root, 'shared'))).resolves.toEqual([]); + }); +}); + +describe('findSharedMirrors', () => { + it('matches a submodule to the member repo of the same name', async () => { + const mirrors = await findSharedMirrors(repos); + expect(mirrors).toHaveLength(1); + expect(mirrors[0]).toMatchObject({ hostName: 'waiter', subPath: 'packages/shared' }); + expect(mirrors[0].canonical.name).toBe('shared'); + }); + + it('ignores a submodule the environment keeps no canonical copy of', async () => { + const mirrors = await findSharedMirrors([repo('waiter')]); + expect(mirrors).toEqual([]); + }); +}); + +describe('alignMirrors', () => { + it('moves the copy from its stale pin onto the canonical base branch', async () => { + const mirrorPath = path.join(root, 'waiter', 'packages/shared'); + expect(fs.readFileSync(path.join(mirrorPath, 'index.ts'), 'utf8')).toContain('version = 1'); + + const outcomes = await alignMirrors(await findSharedMirrors(repos)); + + expect(outcomes).toEqual([{ mirror: 'waiter/packages/shared' }]); + expect(fs.readFileSync(path.join(mirrorPath, 'index.ts'), 'utf8')).toContain('version = 2'); + // Detached: the copy is a build input, not where commits belong. + expect(git(mirrorPath, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe('HEAD'); + }); +}); + +describe('aggregateMirrors', () => { + async function alignedMirrors() { + const mirrors = await findSharedMirrors(repos); + await alignMirrors(mirrors); + return mirrors; + } + + it('carries an edit made in the copy into the canonical checkout, uncommitted', async () => { + const mirrors = await alignedMirrors(); + fs.writeFileSync( + path.join(root, 'waiter', 'packages/shared', 'index.ts'), + 'export const version = 2;\nexport const extra = true;\n', + ); + + const outcomes = await aggregateMirrors(mirrors); + + expect(outcomes).toEqual([{ mirror: 'waiter/packages/shared', applied: true }]); + const canonical = path.join(root, 'shared', 'index.ts'); + expect(fs.readFileSync(canonical, 'utf8')).toContain('extra = true'); + // Left in the working tree: the commit message is the author's. + expect(git(path.join(root, 'shared'), 'status', '--porcelain')).toContain('index.ts'); + }); + + it('carries a new file across too', async () => { + const mirrors = await alignedMirrors(); + fs.writeFileSync( + path.join(root, 'waiter', 'packages/shared', 'added.ts'), + 'export const b = 1;\n', + ); + + await aggregateMirrors(mirrors); + + expect(fs.existsSync(path.join(root, 'shared', 'added.ts'))).toBe(true); + }); + + it('carries commits made inside the copy, not just uncommitted work', async () => { + const mirrors = await alignedMirrors(); + const mirrorPath = path.join(root, 'waiter', 'packages/shared'); + fs.writeFileSync(path.join(mirrorPath, 'index.ts'), 'export const version = 3;\n'); + git(mirrorPath, 'add', '.'); + git(mirrorPath, 'commit', '--quiet', '-m', 'committed in the copy'); + + await aggregateMirrors(mirrors); + + expect(fs.readFileSync(path.join(root, 'shared', 'index.ts'), 'utf8')).toContain('version = 3'); + }); + + it('is idempotent: a second run reports nothing left to carry', async () => { + const mirrors = await alignedMirrors(); + fs.writeFileSync( + path.join(root, 'waiter', 'packages/shared', 'index.ts'), + 'export const version = 2;\nexport const extra = true;\n', + ); + + await aggregateMirrors(mirrors); + expect(await aggregateMirrors(mirrors)).toEqual([ + { mirror: 'waiter/packages/shared', applied: false }, + ]); + }); + + it('reports nothing to do when the copy is untouched', async () => { + expect(await aggregateMirrors(await alignedMirrors())).toEqual([ + { mirror: 'waiter/packages/shared', applied: false }, + ]); + }); +}); + +describe('unaggregatedMirrors', () => { + it('names a copy holding changes, and goes quiet once they are carried across', async () => { + const mirrors = await findSharedMirrors(repos); + await alignMirrors(mirrors); + fs.writeFileSync( + path.join(root, 'waiter', 'packages/shared', 'index.ts'), + 'export const version = 2;\nexport const extra = true;\n', + ); + + expect(await unaggregatedMirrors(mirrors)).toEqual(['waiter/packages/shared']); + await aggregateMirrors(mirrors); + expect(await unaggregatedMirrors(mirrors)).toEqual([]); + }); +}); diff --git a/electron/ipc/pool-shared.ts b/electron/ipc/pool-shared.ts new file mode 100644 index 00000000..894d4670 --- /dev/null +++ b/electron/ipc/pool-shared.ts @@ -0,0 +1,212 @@ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { runGit } from './git.js'; +import type { PoolRepo } from './pool.js'; + +/** + * Shared repositories that appear twice in an environment. + * + * Some workspaces check a shared library out both as a sibling repository at + * the environment root and as a submodule inside each application that uses + * it. The applications build against their submodule copy, so that is where a + * change has to be made for the running application to pick it up — but the + * copies are pinned independently and drift apart, so it is not where the + * change should be committed. + * + * This module treats the sibling checkout as canonical: every copy is put on + * the canonical repository's base branch before work starts, so all of them + * share one base, and the change made in a copy is then carried back to the + * canonical checkout as a patch that applies cleanly by construction. What is + * committed and reviewed is then the same code that was actually run. + * + * Without the alignment step this would be applying a diff across a pin gap, + * which can conflict or — worse — apply cleanly against stale code. + */ + +/** One copy of a canonical member repository, living inside another one. */ +export interface SharedMirror { + /** Member repo the copy lives in, e.g. `waiter`. */ + hostName: string; + /** Path of the copy, relative to its host, e.g. `packages/shared`. */ + subPath: string; + /** Absolute path of the copy's checkout. */ + path: string; + /** The canonical member repo it mirrors, e.g. the `shared` member. */ + canonical: PoolRepo; +} + +/** Submodule paths declared by a repository's `.gitmodules`. */ +export async function submodulePaths(repoPath: string): Promise { + if (!fs.existsSync(path.join(repoPath, '.gitmodules'))) return []; + const out = await runGit(repoPath, [ + 'config', + '--file', + '.gitmodules', + '--get-regexp', + '^submodule\\..*\\.path$', + ]).catch(() => ''); + return out + .split('\n') + .map((line) => line.trim().split(/\s+/)[1]) + .filter((value): value is string => Boolean(value)); +} + +/** + * Every submodule in the environment that mirrors one of its member repos. + * + * The match is by directory name: `packages/shared` inside `waiter` mirrors + * the member repo called `shared`. A submodule with no member of that name — + * a vendored dependency, a skills checkout — is not a mirror and is left + * alone, which is what keeps this from touching submodules the workspace does + * not also keep a canonical copy of. + */ +export async function findSharedMirrors(repos: PoolRepo[]): Promise { + const byName = new Map(repos.map((repo) => [repo.name, repo])); + const mirrors: SharedMirror[] = []; + for (const host of repos) { + for (const subPath of await submodulePaths(host.path)) { + const canonical = byName.get(path.basename(subPath)); + if (!canonical || canonical.path === host.path) continue; + const mirrorPath = path.join(host.path, subPath); + if (!fs.existsSync(mirrorPath)) continue; + mirrors.push({ hostName: host.name, subPath, path: mirrorPath, canonical }); + } + } + return mirrors; +} + +/** The commit a canonical repo's base branch is at, which every copy aligns to. */ +async function canonicalBase(canonical: PoolRepo): Promise { + return runGit(canonical.path, ['rev-parse', canonical.baseBranch]); +} + +export interface MirrorOutcome { + mirror: string; + error?: string; +} + +/** + * Put every mirror on its canonical repository's base branch. + * + * The commit is fetched from the canonical checkout on disk rather than from + * the network: it is the same upstream repository, the objects are already + * local, and a lease should not depend on connectivity. + * + * The copy is left detached on purpose. It is a build input for the duration + * of the task, not somewhere commits belong — the canonical checkout is where + * the branch and the commits live. + */ +export async function alignMirrors(mirrors: SharedMirror[]): Promise { + const outcomes: MirrorOutcome[] = []; + for (const mirror of mirrors) { + const label = `${mirror.hostName}/${mirror.subPath}`; + try { + const base = await canonicalBase(mirror.canonical); + await runGit(mirror.path, ['fetch', '--no-tags', mirror.canonical.path, base]); + await runGit(mirror.path, ['checkout', '--detach', base]); + outcomes.push({ mirror: label }); + } catch (err) { + outcomes.push({ mirror: label, error: String(err) }); + } + } + return outcomes; +} + +/** Whether a mirror holds work that is not in its canonical checkout yet. */ +export async function mirrorPatch(mirror: SharedMirror): Promise { + const base = await canonicalBase(mirror.canonical); + // Intent-to-add so a brand-new file appears in the diff; the index entry is + // undone with the rest of the checkout when the environment is released. + await runGit(mirror.path, ['add', '-A', '-N']).catch(() => ''); + return runGit(mirror.path, ['diff', '--binary', base]); +} + +export interface AggregateOutcome extends MirrorOutcome { + /** Set when the mirror had changes that were carried across. */ + applied?: boolean; +} + +/** + * Carry each mirror's changes into its canonical checkout. + * + * The patch is taken against the canonical base the mirror was aligned to, so + * it covers both commits made inside the copy and uncommitted work, and it + * applies to a checkout branched from that same base. + * + * The result is left in the canonical checkout's working tree rather than + * committed: the message is the author's to write, and this runs before a + * commit rather than instead of one. A patch that does not apply is reported + * and the others still run — one conflicted library should not hide the rest. + */ +export async function aggregateMirrors(mirrors: SharedMirror[]): Promise { + const outcomes: AggregateOutcome[] = []; + for (const mirror of mirrors) { + const label = `${mirror.hostName}/${mirror.subPath}`; + try { + const patch = await mirrorPatch(mirror); + if (!patch.trim() || (await patchIsPresent(mirror.canonical.path, patch))) { + outcomes.push({ mirror: label, applied: false }); + continue; + } + await runGitApply(mirror.canonical.path, ['--3way', '--whitespace=nowarn'], patch); + outcomes.push({ mirror: label, applied: true }); + } catch (err) { + outcomes.push({ mirror: label, error: String(err) }); + } + } + return outcomes; +} + +/** Run `git apply` with a patch on stdin, so nothing is written to disk. */ +async function runGitApply(repoPath: string, args: string[], patch: string): Promise { + await new Promise((resolve, reject) => { + const proc = spawn('git', ['apply', ...args, '-'], { cwd: repoPath }); + let stderr = ''; + proc.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8').slice(0, 4096); + }); + proc.on('error', reject); + proc.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(stderr.trim() || `git apply exited ${code}`)); + }); + proc.stdin?.end(patch.endsWith('\n') ? patch : `${patch}\n`); + }); +} + +/** + * Whether a checkout already contains a patch's changes. + * + * A patch that reverses cleanly is one that is already applied — the standard + * idiom, and the only way to tell "carried across already" apart from "carried + * across and then edited further" without keeping state of our own. + */ +async function patchIsPresent(repoPath: string, patch: string): Promise { + return runGitApply(repoPath, ['--check', '--reverse'], patch).then( + () => true, + () => false, + ); +} + +/** + * Mirrors still holding changes their canonical checkout has not taken. + * + * Used to refuse a push that would otherwise send the application branches + * without the shared change they were written against. + */ +export async function unaggregatedMirrors(mirrors: SharedMirror[]): Promise { + const pending: string[] = []; + for (const mirror of mirrors) { + try { + const patch = await mirrorPatch(mirror); + if (!patch.trim()) continue; + if (await patchIsPresent(mirror.canonical.path, patch)) continue; + pending.push(`${mirror.hostName}/${mirror.subPath}`); + } catch { + // Unreadable copies are the environment status's problem, not this one. + } + } + return pending; +} diff --git a/electron/ipc/pool.test.ts b/electron/ipc/pool.test.ts new file mode 100644 index 00000000..1b156169 --- /dev/null +++ b/electron/ipc/pool.test.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { acquireEnv, clearLease, envStatus, readLease, releaseEnv } from './pool.js'; + +let root: string; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + }, + }).trim(); +} + +/** An environment with a repos.tsv manifest and one committed repo per name. */ +function makeEnv(name: string, repos: string[]): string { + const envPath = path.join(root, name); + fs.mkdirSync(envPath, { recursive: true }); + fs.writeFileSync( + path.join(envPath, 'repos.tsv'), + repos.map((repo) => `${repo}\tgit@example.com:acme/${repo}.git`).join('\n'), + ); + for (const repo of repos) { + const repoPath = path.join(envPath, repo); + fs.mkdirSync(repoPath); + git(repoPath, 'init', '--initial-branch=dev', '--quiet'); + fs.writeFileSync(path.join(repoPath, 'README.md'), `# ${repo}\n`); + git(repoPath, 'add', '.'); + git(repoPath, 'commit', '--quiet', '-m', 'initial'); + } + return envPath; +} + +function branchOf(repoPath: string): string { + return git(repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'); +} + +function branchExists(repoPath: string, branch: string): boolean { + return git(repoPath, 'branch', '--list', branch).length > 0; +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pool-')); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('envStatus', () => { + it('reports a ready environment with no blockers', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + const status = await envStatus(envPath, undefined, new Set()); + expect(status.blockers).toEqual([]); + expect(status.members.map((member) => member.name)).toEqual(['waiter', 'api']); + expect(status.lease).toBeNull(); + }); + + it('blocks on a missing folder', async () => { + const status = await envStatus(path.join(root, 'nope'), undefined, new Set()); + expect(status.exists).toBe(false); + expect(status.blockers).toEqual([{ reason: 'Environment folder not found' }]); + }); + + it('names the repo that is dirty rather than failing the environment', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + fs.writeFileSync(path.join(envPath, 'waiter', 'scratch.txt'), 'wip'); + const status = await envStatus(envPath, undefined, new Set()); + expect(status.blockers).toEqual([{ repo: 'waiter', reason: 'Has uncommitted changes' }]); + }); + + it('reports a declared-but-uncloned repo without refusing the environment', async () => { + // Manifests drift from the checkouts beside them: the Winston dev-env's + // repos.tsv names three repositories nobody clones. Blocking on those + // would make the pool unusable. + const envPath = makeEnv('MRW3', ['waiter']); + fs.appendFileSync(path.join(envPath, 'repos.tsv'), '\napi\tgit@example.com:acme/api.git\n'); + const status = await envStatus(envPath, undefined, new Set()); + expect(status.missing).toEqual(['api']); + expect(status.blockers).toEqual([]); + }); + + it('treats a lease whose task is gone as free', async () => { + const envPath = makeEnv('MRW1', ['waiter']); + await acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'old work', + branchName: 'task/one', + liveTaskIds: ['task-1'], + }); + git(path.join(envPath, 'waiter'), 'checkout', '--quiet', 'dev'); + git(path.join(envPath, 'waiter'), 'branch', '-D', 'task/one'); + + expect((await envStatus(envPath, undefined, new Set(['task-1']))).lease).not.toBeNull(); + expect((await envStatus(envPath, undefined, new Set())).lease).toBeNull(); + }); +}); + +describe('acquireEnv', () => { + it('leases the first free environment and branches every member repo', async () => { + const one = makeEnv('MRW1', ['waiter', 'api']); + makeEnv('MRW2', ['waiter', 'api']); + + const result = await acquireEnv({ + envPaths: [one, path.join(root, 'MRW2')], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }); + + expect(result.envPath).toBe(one); + expect(result.repos.map((repo) => repo.name)).toEqual(['waiter', 'api']); + expect(result.repos.every((repo) => repo.baseBranch === 'dev')).toBe(true); + expect(branchOf(path.join(one, 'waiter'))).toBe('task/win-1'); + expect(branchOf(path.join(one, 'api'))).toBe('task/win-1'); + expect(readLease(one)?.taskId).toBe('task-1'); + }); + + it('skips a leased environment and takes the next one', async () => { + const one = makeEnv('MRW1', ['waiter']); + const two = makeEnv('MRW2', ['waiter']); + await acquireEnv({ + envPaths: [one, two], + taskId: 'task-1', + taskName: 'first', + branchName: 'task/one', + liveTaskIds: [], + }); + + const second = await acquireEnv({ + envPaths: [one, two], + taskId: 'task-2', + taskName: 'second', + branchName: 'task/two', + liveTaskIds: ['task-1'], + }); + expect(second.envPath).toBe(two); + }); + + it('fails with every environment’s reason when the pool is full', async () => { + const one = makeEnv('MRW1', ['waiter']); + const two = makeEnv('MRW2', ['waiter']); + fs.writeFileSync(path.join(two, 'waiter', 'scratch.txt'), 'wip'); + await acquireEnv({ + envPaths: [one, two], + taskId: 'task-1', + taskName: 'first', + branchName: 'task/one', + liveTaskIds: [], + }); + + await expect( + acquireEnv({ + envPaths: [one, two], + taskId: 'task-2', + taskName: 'second', + branchName: 'task/two', + liveTaskIds: ['task-1'], + }), + ).rejects.toThrow(/No pool environment is free[\s\S]*Leased by task "first"[\s\S]*uncommitted/); + }); + + it('rolls the whole environment back when one repo refuses the branch', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + // `api` already has the branch, so creating it there fails after `waiter` + // has already been switched. + git(path.join(envPath, 'api'), 'branch', 'task/win-1'); + + await expect( + acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }), + ).rejects.toThrow(/nothing was changed/); + + expect(branchOf(path.join(envPath, 'waiter'))).toBe('dev'); + expect(branchExists(path.join(envPath, 'waiter'), 'task/win-1')).toBe(false); + expect(readLease(envPath)).toBeNull(); + }); +}); + +describe('releaseEnv', () => { + it('restores every repo and drops an unused branch', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + const { repos } = await acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }); + + const result = await releaseEnv({ envPath, repos }); + + expect(result).toEqual({ keptBranches: [], failures: [] }); + expect(branchOf(path.join(envPath, 'waiter'))).toBe('dev'); + expect(branchExists(path.join(envPath, 'waiter'), 'task/win-1')).toBe(false); + expect(readLease(envPath)).toBeNull(); + }); + + it('keeps a branch that holds commits, and deletes it when forced', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + const { repos } = await acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }); + const waiter = path.join(envPath, 'waiter'); + fs.writeFileSync(path.join(waiter, 'feature.txt'), 'done\n'); + git(waiter, 'add', '.'); + git(waiter, 'commit', '--quiet', '-m', 'WIN-1 | feature'); + + const kept = await releaseEnv({ envPath, repos }); + expect(kept.keptBranches).toEqual(['waiter']); + expect(branchOf(waiter)).toBe('dev'); + expect(branchExists(waiter, 'task/win-1')).toBe(true); + // The unused branch in the sibling repo is gone either way. + expect(branchExists(path.join(envPath, 'api'), 'task/win-1')).toBe(false); + + clearLease(envPath); + const forced = await releaseEnv({ envPath, repos, force: true }); + expect(forced.keptBranches).toEqual([]); + expect(branchExists(waiter, 'task/win-1')).toBe(false); + }); + + it('reports a repo it could not restore and still frees the environment', async () => { + const envPath = makeEnv('MRW1', ['waiter']); + const { repos } = await acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }); + fs.rmSync(path.join(envPath, 'waiter'), { recursive: true, force: true }); + + const result = await releaseEnv({ envPath, repos }); + expect(result.failures.map((failure) => failure.repo)).toEqual(['waiter']); + expect(readLease(envPath)).toBeNull(); + }); +}); diff --git a/electron/ipc/pool.ts b/electron/ipc/pool.ts new file mode 100644 index 00000000..d4f68186 --- /dev/null +++ b/electron/ipc/pool.ts @@ -0,0 +1,343 @@ +import fs from 'fs'; +import path from 'path'; + +import { atomicWriteFile } from '../mcp/atomic.js'; +import { runGit } from './git.js'; +import { discoverMembers, type PoolMemberSpec } from './pool-members.js'; +import { alignMirrors, findSharedMirrors } from './pool-shared.js'; + +/** + * Leasing for pooled workspaces. + * + * A pooled environment is not built per task: it is one of a fixed set of + * ready checkouts that a task takes for its lifetime and gives back. That is + * the whole reason the mode exists — the environments already hold installed + * dependencies, initialised submodules and warm build caches, none of which + * survive being copied and none of which a worktree gets for free. + * + * Because an environment is a real directory a person can also `cd` into, the + * lease is written into it as well as held in app state. A colleague, a second + * app instance, or the same user in a terminal can see that MRW3 is taken. + */ + +/** Directory the pool keeps its own files in, inside each environment. */ +const POOL_DIR = '.parallel-code'; +const LEASE_FILE = 'lease.json'; + +export interface PoolLease { + taskId: string; + taskName: string; + branchName: string; + acquiredAt: number; + /** Process that took the lease, for a human reading the file. */ + pid: number; +} + +export interface PoolRepo { + name: string; + path: string; + branchName: string; + baseBranch: string; +} + +/** Why an environment cannot be leased right now. */ +export interface EnvBlocker { + /** Member repository the problem is in, or undefined for the environment. */ + repo?: string; + reason: string; +} + +export interface EnvStatus { + envPath: string; + exists: boolean; + members: PoolMemberSpec[]; + /** Declared by a manifest or the project, but not checked out yet. */ + missing: string[]; + lease: PoolLease | null; + /** Empty when the environment is ready to lease. */ + blockers: EnvBlocker[]; +} + +function leasePath(envPath: string): string { + return path.join(envPath, POOL_DIR, LEASE_FILE); +} + +export function readLease(envPath: string): PoolLease | null { + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(leasePath(envPath), 'utf8')); + } catch { + return null; // absent, unreadable, or half-written: treat as free + } + if (!raw || typeof raw !== 'object') return null; + const lease = raw as Record; + if (typeof lease.taskId !== 'string' || !lease.taskId) return null; + return { + taskId: lease.taskId, + taskName: typeof lease.taskName === 'string' ? lease.taskName : '', + branchName: typeof lease.branchName === 'string' ? lease.branchName : '', + acquiredAt: typeof lease.acquiredAt === 'number' ? lease.acquiredAt : 0, + pid: typeof lease.pid === 'number' ? lease.pid : 0, + }; +} + +async function writeLease(envPath: string, lease: PoolLease): Promise { + fs.mkdirSync(path.join(envPath, POOL_DIR), { recursive: true }); + await atomicWriteFile(leasePath(envPath), `${JSON.stringify(lease, null, 2)}\n`); +} + +export function clearLease(envPath: string): void { + try { + fs.unlinkSync(leasePath(envPath)); + } catch { + // Already gone — releasing twice is not an error. + } +} + +/** + * A lease whose task no longer exists is stale: the app was killed, or the + * task was removed while the environment was unreachable. Reclaiming it is + * safe because the environment's own cleanliness is checked separately, and + * refusing would strand the environment until someone deleted a file by hand. + */ +function isStaleLease(lease: PoolLease | null, liveTaskIds: ReadonlySet): boolean { + return lease !== null && !liveTaskIds.has(lease.taskId); +} + +/** Whether a repository has uncommitted changes, including untracked files. */ +async function isDirty(repoPath: string): Promise { + return (await runGit(repoPath, ['status', '--porcelain'])).length > 0; +} + +/** Current branch, or null when HEAD is detached. */ +async function currentBranch(repoPath: string): Promise { + const name = await runGit(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']); + return name === 'HEAD' ? null : name; +} + +/** + * Inspect one environment: what it holds, who has it, and what stands between + * it and a lease. + * + * Blockers are collected rather than thrown on the first one, because the + * point of the report is to tell someone everything they have to fix before + * this environment is usable — three round trips to find three dirty repos is + * the failure mode this avoids. + * + * A repository the manifest declares but that is not cloned is reported in + * `missing` and is deliberately *not* a blocker. Manifests drift from the + * checkouts beside them — they gain repositories nobody clones and lose ones + * everybody has — and refusing an otherwise healthy environment over a line in + * a file would make the pool unusable for the workspaces this mode exists for. + */ +export async function envStatus( + envPath: string, + configuredMembers: string[] | undefined, + liveTaskIds: ReadonlySet, +): Promise { + if (!fs.existsSync(envPath)) { + return { + envPath, + exists: false, + members: [], + missing: [], + lease: null, + blockers: [{ reason: 'Environment folder not found' }], + }; + } + + const { members, missing } = discoverMembers(envPath, configuredMembers); + const rawLease = readLease(envPath); + const lease = isStaleLease(rawLease, liveTaskIds) ? null : rawLease; + const blockers: EnvBlocker[] = []; + + if (lease) { + blockers.push({ reason: `Leased by task "${lease.taskName || lease.taskId}"` }); + } + if (members.length === 0) { + blockers.push({ reason: 'No git repositories found in this environment' }); + } + + await Promise.all( + members.map(async (member) => { + const repoPath = path.join(envPath, member.name); + try { + if (await isDirty(repoPath)) { + blockers.push({ repo: member.name, reason: 'Has uncommitted changes' }); + } + if ((await currentBranch(repoPath)) === null) { + blockers.push({ repo: member.name, reason: 'HEAD is detached' }); + } + } catch (err) { + blockers.push({ repo: member.name, reason: `Not readable by git: ${String(err)}` }); + } + }), + ); + + return { envPath, exists: true, members, missing, lease, blockers }; +} + +export interface AcquireArgs { + envPaths: string[]; + configuredMembers?: string[]; + taskId: string; + taskName: string; + branchName: string; + /** Task ids the app still holds, so an abandoned lease can be reclaimed. */ + liveTaskIds: string[]; +} + +export interface AcquireResult { + envPath: string; + repos: PoolRepo[]; +} + +/** + * Lease the first ready environment and branch every member repository in it. + * + * All member repos get the branch, not just the ones the task turns out to + * touch: the platform convention is one branch name across every repository a + * change spans, the branch is free to create when HEAD is already at base, and + * an unused one is deleted again on release. Deciding up front also means the + * agent never has to ask permission to start editing a second repo. + * + * Creation is all-or-nothing. A repository that refuses the branch rolls the + * earlier ones back and frees the lease, because a half-branched environment + * is worse than none: the next task would lease it, find it clean, and quietly + * inherit the leftovers. + */ +export async function acquireEnv(args: AcquireArgs): Promise { + const liveTaskIds = new Set(args.liveTaskIds); + const reports: EnvStatus[] = []; + + for (const envPath of args.envPaths) { + const status = await envStatus(envPath, args.configuredMembers, liveTaskIds); + reports.push(status); + if (status.blockers.length > 0) continue; + + await writeLease(envPath, { + taskId: args.taskId, + taskName: args.taskName, + branchName: args.branchName, + acquiredAt: Date.now(), + pid: process.pid, + }); + + const created: PoolRepo[] = []; + try { + for (const member of status.members) { + const repoPath = path.join(envPath, member.name); + const baseBranch = (await currentBranch(repoPath)) ?? member.branch ?? 'HEAD'; + await runGit(repoPath, ['checkout', '-b', args.branchName]); + created.push({ + name: member.name, + path: repoPath, + branchName: args.branchName, + baseBranch, + }); + } + } catch (err) { + await rollbackBranches(created); + clearLease(envPath); + throw new Error( + `Could not branch every repository in ${envPath}, so nothing was changed: ${String(err)}`, + ); + } + // Every copy of a shared library goes onto its canonical repository's base + // branch before the agent starts, so the application builds against the + // code the change will be committed against. A copy that will not align is + // reported rather than fatal: the task is still workable, it just cannot + // carry that library's changes back cleanly. + const alignment = await alignMirrors(await findSharedMirrors(created)).catch(() => []); + for (const outcome of alignment) { + if (outcome.error) console.warn(`Could not align ${outcome.mirror}:`, outcome.error); + } + + return { envPath, repos: created }; + } + + throw new Error(describeUnavailable(reports)); +} + +async function rollbackBranches(repos: PoolRepo[]): Promise { + for (const repo of repos) { + try { + await runGit(repo.path, ['checkout', repo.baseBranch]); + await runGit(repo.path, ['branch', '-D', repo.branchName]); + } catch (err) { + console.warn(`Could not roll back ${repo.branchName} in ${repo.path}:`, err); + } + } +} + +/** One message naming why each environment was passed over. */ +function describeUnavailable(reports: EnvStatus[]): string { + if (reports.length === 0) return 'This project has no pool environments configured.'; + const lines = reports.map((report) => { + const detail = report.blockers + .map((blocker) => (blocker.repo ? `${blocker.repo}: ${blocker.reason}` : blocker.reason)) + .join('; '); + return ` ${report.envPath} — ${detail || 'unavailable'}`; + }); + return `No pool environment is free:\n${lines.join('\n')}`; +} + +export interface ReleaseArgs { + envPath: string; + repos: PoolRepo[]; + /** Delete the task branch even where it holds commits. */ + force?: boolean; +} + +export interface ReleaseResult { + /** Repos whose task branch was kept because it still holds commits. */ + keptBranches: string[]; + /** Repos that could not be restored; the lease is dropped regardless. */ + failures: { repo: string; reason: string }[]; +} + +/** + * Give an environment back: return every repository to the branch it was on, + * drop task branches that never earned a commit, restore the submodule + * checkouts to the pins of the base branch, and remove the lease. + * + * A branch with commits is kept unless the caller forces it. Losing work + * silently is the one outcome worth more than a tidy environment, and the + * readiness check will not be fooled: the next lease sees the environment is + * clean and on base, which it is. + */ +export async function releaseEnv(args: ReleaseArgs): Promise { + const keptBranches: string[] = []; + const failures: { repo: string; reason: string }[] = []; + + for (const repo of args.repos) { + try { + const onTaskBranch = (await currentBranch(repo.path)) === repo.branchName; + if (onTaskBranch) await runGit(repo.path, ['checkout', repo.baseBranch]); + + const unmerged = await runGit(repo.path, [ + 'log', + `${repo.baseBranch}..${repo.branchName}`, + '--oneline', + ]).catch(() => ''); + if (unmerged && !args.force) { + keptBranches.push(repo.name); + } else { + await runGit(repo.path, ['branch', '-D', repo.branchName]); + } + + // Submodule pins move with the branch; without this the environment + // stays on the task's pins and the next lease inherits them. `--force` is + // required rather than tidy: a copy of a shared library was deliberately + // moved off its pin at lease time and may hold edits, and a plain update + // refuses to check out over those — which would leave the environment + // dirty and unleasable. + await runGit(repo.path, ['submodule', 'update', '--init', '--recursive', '--force']); + } catch (err) { + failures.push({ repo: repo.name, reason: String(err) }); + } + } + + clearLease(args.envPath); + return { keptBranches, failures }; +} diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 13a9e781..e8860c2d 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -76,7 +76,10 @@ import { getUncommittedChangedFiles, getUncommittedFileDiffs, } from './git.js'; -import { createTask, deleteTask } from './tasks.js'; +import { createPoolTask, createTask, deletePoolTask, deleteTask } from './tasks.js'; +import { envStatus, type PoolRepo } from './pool.js'; +import { poolAllDiffs, poolChangedFiles, poolFileDiff, poolStatus } from './pool-git.js'; +import { aggregateMirrors, findSharedMirrors, unaggregatedMirrors } from './pool-shared.js'; import { listAgents } from './agents.js'; import { saveAppState, @@ -567,6 +570,107 @@ export function registerAllHandlers(win: BrowserWindow): void { }); return result; }); + /** + * Pool arguments name directories and branches the main process then runs + * git in, so every one is validated here rather than trusted from the + * renderer — the same rule the worktree handlers follow. + */ + function validatedPoolRepos(value: unknown): PoolRepo[] { + if (!Array.isArray(value)) throw new Error('repos must be an array'); + return value.map((entry, index) => { + if (!entry || typeof entry !== 'object') throw new Error(`repos[${index}] must be an object`); + const repo = entry as Record; + assertString(repo.name, `repos[${index}].name`); + validatePath(repo.path, `repos[${index}].path`); + validateBranchName(repo.branchName, `repos[${index}].branchName`); + validateBranchName(repo.baseBranch, `repos[${index}].baseBranch`); + return { + name: repo.name, + path: repo.path as string, + branchName: repo.branchName as string, + baseBranch: repo.baseBranch as string, + }; + }); + } + + function validatedEnvPaths(value: unknown): string[] { + assertStringArray(value, 'envPaths'); + for (const envPath of value) validatePath(envPath, 'envPaths[]'); + return value; + } + + function optionalMembers(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + assertStringArray(value, 'members'); + return value; + } + + function validatedTaskIds(value: unknown): string[] { + assertStringArray(value, 'liveTaskIds'); + return value; + } + + ipcMain.handle(IPC.PoolEnvStatus, (_e, args) => { + const envPaths = validatedEnvPaths(args.envPaths); + const members = optionalMembers(args.members); + const liveTaskIds = new Set(validatedTaskIds(args.liveTaskIds)); + return Promise.all(envPaths.map((envPath) => envStatus(envPath, members, liveTaskIds))); + }); + + ipcMain.handle(IPC.PoolAcquireEnv, (_e, args) => { + const envPaths = validatedEnvPaths(args.envPaths); + assertString(args.name, 'name'); + assertOptionalString(args.branchPrefix, 'branchPrefix'); + const result = createPoolTask({ + name: args.name, + branchPrefix: args.branchPrefix ?? 'task', + envPaths, + members: optionalMembers(args.members), + liveTaskIds: validatedTaskIds(args.liveTaskIds), + }); + result + .then((r: { id: string }) => taskNames.set(r.id, args.name)) + .catch((err: unknown) => { + logWarn('tasks', 'createPoolTask resolution failed', { err: errMessage(err) }); + }); + return result; + }); + + ipcMain.handle(IPC.PoolReleaseEnv, (_e, args) => { + validatePath(args.envPath, 'envPath'); + assertStringArray(args.agentIds, 'agentIds'); + assertOptionalString(args.taskId, 'taskId'); + assertOptionalBoolean(args.force, 'force'); + return deletePoolTask({ + taskId: args.taskId, + agentIds: args.agentIds, + envPath: args.envPath, + repos: validatedPoolRepos(args.repos), + force: args.force, + }); + }); + + ipcMain.handle(IPC.PoolChangedFiles, (_e, args) => + poolChangedFiles(validatedPoolRepos(args.repos)), + ); + + ipcMain.handle(IPC.PoolAllDiffs, (_e, args) => poolAllDiffs(validatedPoolRepos(args.repos))); + + ipcMain.handle(IPC.PoolFileDiff, (_e, args) => { + assertString(args.filePath, 'filePath'); + return poolFileDiff(validatedPoolRepos(args.repos), args.filePath); + }); + + ipcMain.handle(IPC.PoolStatus, (_e, args) => poolStatus(validatedPoolRepos(args.repos))); + + ipcMain.handle(IPC.PoolSharedPending, async (_e, args) => + unaggregatedMirrors(await findSharedMirrors(validatedPoolRepos(args.repos))), + ); + + ipcMain.handle(IPC.PoolSharedAggregate, async (_e, args) => + aggregateMirrors(await findSharedMirrors(validatedPoolRepos(args.repos))), + ); + ipcMain.handle(IPC.DeleteTask, (_e, args) => { assertStringArray(args.agentIds, 'agentIds'); validatePath(args.projectRoot, 'projectRoot'); @@ -697,7 +801,8 @@ export function registerAllHandlers(win: BrowserWindow): void { const projectRoot = projectRootArg(args); const branchName = branchNameArg(args); assertString(args.onOutput?.__CHANNEL_ID__, 'channelId'); - return pushTask(win, projectRoot, branchName, args.onOutput.__CHANNEL_ID__); + assertOptionalString(args.label, 'label'); + return pushTask(win, projectRoot, branchName, args.onOutput.__CHANNEL_ID__, args.label); }); ipcMain.handle(IPC.RebaseTask, (_e, args) => { const worktreePath = worktreePathArg(args); diff --git a/electron/ipc/shared-types.ts b/electron/ipc/shared-types.ts index b0ae28be..253b0c4a 100644 --- a/electron/ipc/shared-types.ts +++ b/electron/ipc/shared-types.ts @@ -27,6 +27,27 @@ export interface CreateTaskResult { worktree_path: string; } +/** One member repository of a leased environment, as the renderer sees it. */ +export interface PoolTaskRepo { + name: string; + path: string; + branchName: string; + baseBranch: string; +} + +export interface CreatePoolTaskResult { + id: string; + branch_name: string; + env_path: string; + repos: PoolTaskRepo[]; +} + +export interface ReleasePoolEnvResult { + /** Repos whose task branch was kept because it still holds commits. */ + keptBranches: string[]; + failures: { repo: string; reason: string }[]; +} + export interface SymlinkCandidate { name: string; isDefault: boolean; diff --git a/electron/ipc/tasks.ts b/electron/ipc/tasks.ts index d24a7375..f0f77bed 100644 --- a/electron/ipc/tasks.ts +++ b/electron/ipc/tasks.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; import { createWorktree, removeWorktree } from './git.js'; +import { acquireEnv, releaseEnv, type PoolRepo } from './pool.js'; import { killAgent, notifyAgentListChanged } from './pty.js'; import { stopPlanWatcher } from './plans.js'; import { stopStepsWatcher } from './steps.js'; @@ -48,6 +49,69 @@ export async function createTask( }; } +export interface CreatePoolTaskArgs { + name: string; + branchPrefix: string; + envPaths: string[]; + members?: string[]; + liveTaskIds: string[]; +} + +/** + * Create a task that leases a pooled environment. + * + * The branch name is derived here rather than in the renderer so a pool task + * and a worktree task of the same name get the same branch: one slug rule, one + * prefix rule, one place to change them. + */ +export async function createPoolTask(args: CreatePoolTaskArgs): Promise<{ + id: string; + branch_name: string; + env_path: string; + repos: PoolRepo[]; +}> { + const id = randomUUID(); + const prefix = sanitizeBranchPrefix(args.branchPrefix); + const branchName = `${prefix}/${slug(args.name)}-${id.slice(0, 6)}`; + const { envPath, repos } = await acquireEnv({ + envPaths: args.envPaths, + configuredMembers: args.members, + taskId: id, + taskName: args.name, + branchName, + liveTaskIds: args.liveTaskIds, + }); + return { id, branch_name: branchName, env_path: envPath, repos }; +} + +export interface DeletePoolTaskOpts { + taskId?: string; + agentIds: string[]; + envPath: string; + repos: PoolRepo[]; + /** Delete the task branch even where it holds commits. */ + force?: boolean; +} + +/** Stop a pool task's agents and hand its environment back to the pool. */ +export async function deletePoolTask(opts: DeletePoolTaskOpts): Promise<{ + keptBranches: string[]; + failures: { repo: string; reason: string }[]; +}> { + if (opts.taskId) stopPlanWatcher(opts.taskId); + if (opts.taskId) stopStepsWatcher(opts.taskId); + for (const agentId of opts.agentIds) { + try { + killAgent(agentId); + } catch { + /* already dead */ + } + } + const result = await releaseEnv({ envPath: opts.envPath, repos: opts.repos, force: opts.force }); + notifyAgentListChanged(); + return result; +} + interface DeleteTaskOpts { taskId?: string; agentIds: string[]; diff --git a/electron/preload.cjs b/electron/preload.cjs index 1cca2d99..05a102da 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -17,6 +17,15 @@ const ALLOWED_CHANNELS = new Set([ 'list_agents', 'create_task', 'delete_task', + 'pool_env_status', + 'pool_acquire_env', + 'pool_release_env', + 'pool_changed_files', + 'pool_file_diff', + 'pool_all_diffs', + 'pool_status', + 'pool_shared_pending', + 'pool_shared_aggregate', 'get_changed_files', 'get_changed_files_from_branch', 'get_all_file_diffs', diff --git a/src/components/ChangedFilesList.tsx b/src/components/ChangedFilesList.tsx index c951db0e..8318ea1d 100644 --- a/src/components/ChangedFilesList.tsx +++ b/src/components/ChangedFilesList.tsx @@ -19,7 +19,7 @@ import { isCommitHashSelection, isUncommittedSelection, } from './CommitNavBar'; -import type { ChangedFile, CoverageFileSummary, CoverageSummary } from '../ipc/types'; +import type { ChangedFile, CoverageFileSummary, CoverageSummary, PoolTaskRepo } from '../ipc/types'; interface ChangedFilesListProps { worktreePath: string; @@ -36,6 +36,8 @@ interface ChangedFilesListProps { coverageReportPath?: string; /** Project root for branch-based fallback when worktree doesn't exist */ projectRoot?: string; + /** Member repos of a leased environment; set only for pool tasks. */ + poolRepos?: PoolTaskRepo[]; /** Branch name for branch-based fallback when worktree doesn't exist */ branchName?: string | null; /** Base branch for diff comparison (e.g. 'main', 'develop'). Undefined = auto-detect. */ @@ -620,6 +622,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { createEffect(() => { void props.worktreePath; void props.projectRoot; + void props.poolRepos; void props.branchName; void props.baseBranch; void props.selectedCommit; @@ -653,6 +656,38 @@ export function ChangedFilesList(props: ChangedFilesListProps) { if (inFlight) return; inFlight = true; try { + // A pool task's changes are spread over its environment's member + // repositories, so the list comes from the fan-out. Paths arrive + // prefixed with the repo name, which makes them relative to the + // environment root — the very path this panel already holds, so + // opening a file in an editor keeps working unchanged. + const poolRepos = props.poolRepos; + if (poolRepos && poolRepos.length > 0) { + try { + const result = await invoke(IPC.PoolChangedFiles, { repos: poolRepos }); + if (!cancelled) { + batch(() => { + setFiles((current) => (sameChangedFiles(current, result) ? current : result)); + setComparisonFiles((current) => + sameChangedFiles(current, result) ? current : result, + ); + setComparisonInventoryState('available'); + setCanOpenFilesInEditor(true); + }); + } + } catch { + if (!cancelled) { + batch(() => { + setFiles([]); + setComparisonFiles(null); + setComparisonInventoryState('failed'); + setCanOpenFilesInEditor(false); + }); + } + } + return; + } + // Single-commit mode: fetch files for that commit only if (singleCommitHash && path) { try { diff --git a/src/components/CloseTaskDialog.tsx b/src/components/CloseTaskDialog.tsx index 342a103b..c7d84a55 100644 --- a/src/components/CloseTaskDialog.tsx +++ b/src/components/CloseTaskDialog.tsx @@ -55,12 +55,19 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) { )} - +

This will stop all running agents and shells for this task. No git operations will be performed.

+ +

+ This will stop all running agents and shells, then hand the environment back to the + pool: every repository returns to its base branch and the lease is released. A task + branch that still holds commits is kept unless the project deletes branches on close. +

+
void; /** Git isolation mode — CommitNavBar is only shown for worktree-isolated tasks */ gitIsolation?: GitIsolationMode; + /** Member repos of a leased environment; set only for pool tasks. */ + poolRepos?: PoolTaskRepo[]; /** Optional structured-finding source. Providers capture their own repository context. */ findingProvider?: QualityFindingProvider; } @@ -266,6 +269,7 @@ function DiffViewerContent(props: DiffViewerDialogProps & { tour: ChangeTourCont branchName, baseBranch, selectedCommit: selection, + poolRepos: props.poolRepos, }).then(({ rawDiff }) => rawDiff); diffPromise diff --git a/src/components/EditProjectDialog.tsx b/src/components/EditProjectDialog.tsx index 1b21c914..6bcddcd9 100644 --- a/src/components/EditProjectDialog.tsx +++ b/src/components/EditProjectDialog.tsx @@ -1,4 +1,5 @@ import { createSignal, createEffect, For, Show } from 'solid-js'; +import { createStore } from 'solid-js/store'; import { Dialog } from './Dialog'; import { updateProject, PASTEL_HUES, isProjectMissing, relinkProject } from '../store/store'; import { sanitizeBranchPrefix, toBranchName } from '../lib/branch-name'; @@ -9,6 +10,7 @@ import { ImportWorktreesDialog } from './ImportWorktreesDialog'; import { CloseIcon } from './icons'; import { RemoveProjectConfirm } from './RemoveProjectConfirm'; import { isDocumentProject } from '../store/projects'; +import { poolFromForm, poolToForm, type PoolFormValues } from '../lib/pool-config'; interface EditProjectDialogProps { project: Project | null; @@ -31,6 +33,12 @@ export function EditProjectDialog(props: EditProjectDialogProps) { const [verifyCommand, setVerifyCommand] = createSignal(''); const [bookmarks, setBookmarks] = createSignal([]); const [newCommand, setNewCommand] = createSignal(''); + const [poolForm, setPoolForm] = createStore({ + envPaths: '', + members: '', + portBase: '', + portOffsets: '', + }); const [showImportDialog, setShowImportDialog] = createSignal(false); const [confirmRemove, setConfirmRemove] = createSignal(false); /** Branch and worktree settings only mean something where tasks run. */ @@ -51,6 +59,7 @@ export function EditProjectDialog(props: EditProjectDialogProps) { setCoverageReportPath(p.coverageReportPath ?? ''); setVerifyCommand(p.verifyCommand ?? ''); setBookmarks(p.terminalBookmarks ? [...p.terminalBookmarks] : []); + setPoolForm(poolToForm(p.pool)); setNewCommand(''); setConfirmRemove(false); requestAnimationFrame(() => nameRef?.focus()); @@ -87,6 +96,7 @@ export function EditProjectDialog(props: EditProjectDialogProps) { coverageReportPath: coverageReportPath().trim() || undefined, verifyCommand: verifyCommand().trim() || undefined, terminalBookmarks: bookmarks(), + pool: poolFromForm(poolForm), }); props.onClose(); } @@ -361,12 +371,78 @@ export function EditProjectDialog(props: EditProjectDialogProps) { options={[ { value: 'worktree', label: 'Worktree' }, { value: 'direct', label: 'Current Branch' }, + ...(poolForm.envPaths.trim() ? [{ value: 'pool', label: 'Pooled Env' }] : []), ]} value={defaultGitIsolation()} onChange={setDefaultGitIsolation} /> + {/* Pooled environments */} +
+ +