Skip to content

Commit 2c320ef

Browse files
authored
Merge pull request #302 from windmill-labs/feat/switch-worktree-profile
feat: switch a worktree to another profile after creation
2 parents 99cb139 + 7bb7819 commit 2c320ef

17 files changed

Lines changed: 613 additions & 24 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ A web dashboard for managing parallel AI coding agents. webmux owns git worktree
1010

1111
![create worktree](https://github.com/windmill-labs/webmux/raw/main/site/static/videos/create.gif)
1212

13-
Spin up new worktrees with one click. Pick a profile, type a prompt, and webmux creates the worktree, starts the agent, and begins streaming output. Merge or remove worktrees when you're done.
13+
Spin up new worktrees with one click. Pick a profile, type a prompt, and webmux creates the worktree, starts the agent, and begins streaming output. Changed your mind about the profile? Switch it later from the worktree menu (or `webmux profile <branch> <profile>`) — the session restarts with the new pane layout and commands, resuming the agent conversation. Merge or remove worktrees when you're done.
1414

1515
### Embedded Terminals
1616

backend/src/__tests__/lifecycle-service.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,21 @@ const NO_DEFAULT_PROFILE_CONFIG: ProjectConfig = {
270270
},
271271
};
272272

273+
const SWITCHABLE_PROFILE_CONFIG: ProjectConfig = {
274+
...TEST_CONFIG,
275+
profiles: {
276+
...TEST_CONFIG.profiles,
277+
full: {
278+
runtime: "host",
279+
envPassthrough: [],
280+
panes: [
281+
{ id: "agent", kind: "agent", focus: true },
282+
{ id: "backend", kind: "command", split: "right", command: "bun run dev" },
283+
],
284+
},
285+
},
286+
};
287+
273288
function makeLifecycleService(
274289
repoRoot: string,
275290
tmux: FakeTmuxGateway,
@@ -1463,6 +1478,141 @@ describe("LifecycleService", () => {
14631478
expect(await Bun.file(paths.controlEnvPath).exists()).toBe(false);
14641479
});
14651480

1481+
it("rebuilds an open worktree session with the panes of the profile it switches to", async () => {
1482+
const repoRoot = await initRepo();
1483+
const runtime = new ProjectRuntime();
1484+
const tmux = new FakeTmuxGateway();
1485+
const lifecycle = makeLifecycleService(
1486+
repoRoot,
1487+
tmux,
1488+
runtime,
1489+
new FakeDockerGateway(),
1490+
new FakeHookRunner(),
1491+
SWITCHABLE_PROFILE_CONFIG,
1492+
);
1493+
1494+
await lifecycle.createWorktree({ branch: "feature-profile", profile: "default" });
1495+
tmux.commands.length = 0;
1496+
1497+
const result = await lifecycle.setWorktreeProfile("feature-profile", "full");
1498+
1499+
expect(result).toEqual({ profile: "full", restarted: true });
1500+
1501+
const worktreePath = join(repoRoot, "__worktrees", "feature-profile");
1502+
const gitDir = new BunGitGateway().resolveWorktreeGitDir(worktreePath);
1503+
const meta = await readWorktreeMeta(gitDir);
1504+
expect(meta?.profile).toBe("full");
1505+
expect(meta?.runtime).toBe("host");
1506+
expect(runtime.getWorktreeByBranch("feature-profile")?.profile).toBe("full");
1507+
1508+
expect(tmux.listWindows()).toEqual([
1509+
{
1510+
sessionName: buildProjectSessionName(repoRoot),
1511+
windowName: buildWorktreeWindowName("feature-profile"),
1512+
paneCount: 2,
1513+
},
1514+
]);
1515+
expect(tmux.commands.map((entry) => entry.command)).toContainEqual("bun run dev");
1516+
expect(tmux.commands[0]?.command).toContain("claude");
1517+
});
1518+
1519+
it("keeps a closed worktree closed and applies the new profile on the next open", async () => {
1520+
const repoRoot = await initRepo();
1521+
const runtime = new ProjectRuntime();
1522+
const tmux = new FakeTmuxGateway();
1523+
const lifecycle = makeLifecycleService(
1524+
repoRoot,
1525+
tmux,
1526+
runtime,
1527+
new FakeDockerGateway(),
1528+
new FakeHookRunner(),
1529+
SWITCHABLE_PROFILE_CONFIG,
1530+
);
1531+
1532+
await lifecycle.createWorktree({ branch: "feature-profile-closed", profile: "default" });
1533+
await lifecycle.closeWorktree("feature-profile-closed");
1534+
1535+
const result = await lifecycle.setWorktreeProfile("feature-profile-closed", "full");
1536+
1537+
expect(result).toEqual({ profile: "full", restarted: false });
1538+
expect(tmux.listWindows()).toEqual([]);
1539+
1540+
tmux.commands.length = 0;
1541+
await lifecycle.openWorktree("feature-profile-closed");
1542+
1543+
expect(tmux.listWindows()[0]?.paneCount).toBe(2);
1544+
expect(tmux.commands.map((entry) => entry.command)).toContainEqual("bun run dev");
1545+
});
1546+
1547+
it("tears down the container when a worktree leaves its docker profile", async () => {
1548+
const repoRoot = await initRepo();
1549+
const runtime = new ProjectRuntime();
1550+
const tmux = new FakeTmuxGateway();
1551+
const docker = new FakeDockerGateway();
1552+
const lifecycle = makeLifecycleService(
1553+
repoRoot,
1554+
tmux,
1555+
runtime,
1556+
docker,
1557+
new FakeHookRunner(),
1558+
SWITCHABLE_PROFILE_CONFIG,
1559+
);
1560+
1561+
await lifecycle.createWorktree({ branch: "feature-profile-docker", profile: "sandbox" });
1562+
1563+
const result = await lifecycle.setWorktreeProfile("feature-profile-docker", "full");
1564+
1565+
expect(result).toEqual({ profile: "full", restarted: true });
1566+
expect(docker.removed).toEqual(["feature-profile-docker"]);
1567+
expect(docker.launched).toHaveLength(1);
1568+
1569+
const worktreePath = join(repoRoot, "__worktrees", "feature-profile-docker");
1570+
const gitDir = new BunGitGateway().resolveWorktreeGitDir(worktreePath);
1571+
expect((await readWorktreeMeta(gitDir))?.runtime).toBe("host");
1572+
const controlEnvText = await Bun.file(getWorktreeStoragePaths(gitDir).controlEnvPath).text();
1573+
expect(controlEnvText).toContain("WEBMUX_CONTROL_URL=http://127.0.0.1:5111/api/runtime/events");
1574+
});
1575+
1576+
it("leaves the session untouched when the profile is unchanged", async () => {
1577+
const repoRoot = await initRepo();
1578+
const runtime = new ProjectRuntime();
1579+
const tmux = new FakeTmuxGateway();
1580+
const lifecycle = makeLifecycleService(
1581+
repoRoot,
1582+
tmux,
1583+
runtime,
1584+
new FakeDockerGateway(),
1585+
new FakeHookRunner(),
1586+
SWITCHABLE_PROFILE_CONFIG,
1587+
);
1588+
1589+
await lifecycle.createWorktree({ branch: "feature-profile-same", profile: "default" });
1590+
tmux.commands.length = 0;
1591+
tmux.createdWindows.length = 0;
1592+
1593+
const result = await lifecycle.setWorktreeProfile("feature-profile-same", "default");
1594+
1595+
expect(result).toEqual({ profile: "default", restarted: false });
1596+
expect(tmux.createdWindows).toEqual([]);
1597+
expect(tmux.commands).toEqual([]);
1598+
});
1599+
1600+
it("rejects switching to an unknown profile", async () => {
1601+
const repoRoot = await initRepo();
1602+
const runtime = new ProjectRuntime();
1603+
const tmux = new FakeTmuxGateway();
1604+
const lifecycle = makeLifecycleService(repoRoot, tmux, runtime);
1605+
1606+
await lifecycle.createWorktree({ branch: "feature-profile-unknown" });
1607+
1608+
await expect(lifecycle.setWorktreeProfile("feature-profile-unknown", "nope"))
1609+
.rejects.toMatchObject({ status: 400, message: "Unknown profile: nope" });
1610+
1611+
const worktreePath = join(repoRoot, "__worktrees", "feature-profile-unknown");
1612+
const gitDir = new BunGitGateway().resolveWorktreeGitDir(worktreePath);
1613+
expect((await readWorktreeMeta(gitDir))?.profile).toBe("default");
1614+
});
1615+
14661616
it("creates a managed docker worktree through the container runtime path", async () => {
14671617
const repoRoot = await initRepo();
14681618
const runtime = new ProjectRuntime();

backend/src/server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
SendWorktreePromptRequestSchema,
2121
SetWorktreeArchivedRequestSchema,
2222
SetWorktreeLabelRequestSchema,
23+
SetWorktreeProfileRequestSchema,
2324
ToggleEnabledRequestSchema,
2425
UpsertCustomAgentRequestSchema,
2526
WorktreeNameParamsSchema,
@@ -1455,6 +1456,18 @@ async function apiSetWorktreeLabel(name: string, req: Request): Promise<Response
14551456
return jsonResponse({ ok: true, label: result.label });
14561457
}
14571458

1459+
async function apiSetWorktreeProfile(name: string, req: Request): Promise<Response> {
1460+
ensureBranchNotBusy(name);
1461+
const parsed = await parseJsonBody(req, SetWorktreeProfileRequestSchema);
1462+
if (!parsed.ok) return parsed.response;
1463+
const body = parsed.data;
1464+
1465+
log.info(`[worktree:profile] name=${name} profile=${body.profile}`);
1466+
const result = await lifecycleService.setWorktreeProfile(name, body.profile);
1467+
log.debug(`[worktree:profile] done name=${name} profile=${result.profile} restarted=${result.restarted}`);
1468+
return jsonResponse({ ok: true, profile: result.profile, restarted: result.restarted });
1469+
}
1470+
14581471
async function apiSendPrompt(name: string, req: Request): Promise<Response> {
14591472
ensureBranchNotBusy(name);
14601473
const parsed = await parseJsonBody(req, SendWorktreePromptRequestSchema);
@@ -2066,6 +2079,15 @@ function parseAgentIdParam(params: Record<string, string>):
20662079
},
20672080
},
20682081

2082+
[apiPaths.setWorktreeProfile]: {
2083+
PUT: (req) => {
2084+
const parsed = parseWorktreeNameParam(req.params);
2085+
if (!parsed.ok) return parsed.response;
2086+
const name = parsed.data;
2087+
return catching(`PUT /api/worktrees/${name}/profile`, () => apiSetWorktreeProfile(name, req));
2088+
},
2089+
},
2090+
20692091
[apiPaths.sendWorktreePrompt]: {
20702092
POST: (req) => {
20712093
const parsed = parseWorktreeNameParam(req.params);

backend/src/services/lifecycle-service.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
} from "./agent-service";
4545
import { getAgentDefinition, type AgentDefinition } from "./agent-registry";
4646
import type { ReconciliationService } from "./reconciliation-service";
47-
import { ensureSessionLayout, planSessionLayout } from "./session-service";
47+
import { ensureSessionLayout, isWorktreeOpen, planSessionLayout } from "./session-service";
4848
import { ArchiveStateService } from "./archive-state-service";
4949
import {
5050
createManagedWorktree,
@@ -765,6 +765,48 @@ export class LifecycleService {
765765
}
766766
}
767767

768+
/** Switch a worktree to another profile. When the worktree is open its tmux
769+
* window is rebuilt from the new profile's pane templates (resuming the agent
770+
* conversation); when it is closed the new profile applies on the next open. */
771+
async setWorktreeProfile(branch: string, profileName: string): Promise<{
772+
profile: string;
773+
restarted: boolean;
774+
}> {
775+
try {
776+
const resolved = await this.resolveExistingWorktree(branch);
777+
if (!resolved.meta) {
778+
throw new LifecycleError(`Worktree ${branch} has no managed metadata to reprofile`, 409);
779+
}
780+
const { profileName: nextProfileName, profile } = this.resolveProfile(profileName);
781+
const wasOpen = isWorktreeOpen(this.deps.tmux, this.deps.projectRoot, branch);
782+
if (resolved.meta.profile === nextProfileName) {
783+
return { profile: nextProfileName, restarted: false };
784+
}
785+
786+
// Containers are keyed by branch and reused on launch, so a worktree leaving
787+
// its docker profile — for host or for another image — needs a teardown first.
788+
if (resolved.meta.runtime === "docker") {
789+
await this.deps.docker.removeContainer(branch);
790+
}
791+
792+
await writeWorktreeMeta(resolved.gitDir, {
793+
...resolved.meta,
794+
profile: nextProfileName,
795+
runtime: profile.runtime,
796+
});
797+
798+
if (wasOpen) {
799+
await this.openWorktree(branch);
800+
} else {
801+
await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
802+
}
803+
804+
return { profile: nextProfileName, restarted: wasOpen };
805+
} catch (error) {
806+
throw this.wrapOperationError(error);
807+
}
808+
}
809+
768810
listAvailableBranches(options: ListAvailableBranchesOptions = {}): Array<{ name: string }> {
769811
const localBranches = this.listLocalBranches().filter((branch) => isValidBranchName(branch));
770812
const remoteBranches = options.includeRemote

bin/src/completions.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface ListBranchesDeps {
1111

1212
// ── Constants ──────────────────────────────────────────────────────────────
1313

14-
const BRANCH_SUBCOMMANDS = new Set(["open", "close", "refresh", "archive", "unarchive", "label", "remove", "merge", "send"]);
14+
const BRANCH_SUBCOMMANDS = new Set(["open", "close", "refresh", "archive", "unarchive", "label", "profile", "remove", "merge", "send"]);
1515

1616
// ── Pure logic ─────────────────────────────────────────────────────────────
1717

@@ -130,6 +130,7 @@ _webmux() {
130130
'archive:Hide a worktree from the default list'
131131
'unarchive:Show an archived worktree again'
132132
'label:Set or clear a workspace label'
133+
'profile:Switch a worktree to another profile'
133134
'remove:Remove a worktree'
134135
'merge:Merge a worktree into main'
135136
'send:Send a prompt to a running worktree agent'
@@ -146,7 +147,7 @@ _webmux() {
146147
fi
147148
148149
case "\${words[2]}" in
149-
open|close|refresh|archive|unarchive|label|remove|merge|send)
150+
open|close|refresh|archive|unarchive|label|profile|remove|merge|send)
150151
if (( CURRENT == 3 )); then
151152
local -a branches
152153
branches=(\${(f)"$(webmux --completions "\${words[2]}" 2>/dev/null)"})
@@ -206,12 +207,12 @@ const BASH_SCRIPT = `_webmux() {
206207
prev="\${COMP_WORDS[COMP_CWORD-1]}"
207208
208209
if [[ \${COMP_CWORD} -eq 1 ]]; then
209-
COMPREPLY=($(compgen -W "serve init service update add oneshot list open close refresh archive unarchive label remove merge send prune restore linear project completion" -- "\${cur}"))
210+
COMPREPLY=($(compgen -W "serve init service update add oneshot list open close refresh archive unarchive label profile remove merge send prune restore linear project completion" -- "\${cur}"))
210211
return
211212
fi
212213
213214
case "\${COMP_WORDS[1]}" in
214-
open|close|refresh|archive|unarchive|label|remove|merge|send)
215+
open|close|refresh|archive|unarchive|label|profile|remove|merge|send)
215216
if [[ \${COMP_CWORD} -eq 2 ]]; then
216217
local branches
217218
branches=$(webmux --completions "\${COMP_WORDS[1]}" 2>/dev/null)

bin/src/webmux.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Usage:
2929
webmux archive Hide a worktree from the default list
3030
webmux unarchive Show an archived worktree again
3131
webmux label Set or clear a workspace label
32+
webmux profile Switch a worktree to another profile
3233
webmux remove Remove a worktree
3334
webmux merge Merge a worktree into the main branch and remove it
3435
webmux send Send a prompt to a running worktree agent
@@ -52,7 +53,7 @@ Environment:
5253
`);
5354
}
5455

55-
type RootCommand = "serve" | "init" | "service" | "update" | "add" | "oneshot" | "list" | "open" | "close" | "refresh" | "archive" | "unarchive" | "label" | "remove" | "merge" | "send" | "tab" | "prune" | "restore" | "linear" | "project" | "completion" | null;
56+
type RootCommand = "serve" | "init" | "service" | "update" | "add" | "oneshot" | "list" | "open" | "close" | "refresh" | "archive" | "unarchive" | "label" | "profile" | "remove" | "merge" | "send" | "tab" | "prune" | "restore" | "linear" | "project" | "completion" | null;
5657

5758
interface ParsedRootArgs {
5859
port: number;
@@ -80,6 +81,7 @@ function isRootCommand(value: string): value is NonNullable<RootCommand> {
8081
|| value === "archive"
8182
|| value === "unarchive"
8283
|| value === "label"
84+
|| value === "profile"
8385
|| value === "remove"
8486
|| value === "merge"
8587
|| value === "send"
@@ -165,7 +167,7 @@ export function parseRootArgs(args: string[]): ParsedRootArgs {
165167
};
166168
}
167169

168-
function isWorktreeCommand(command: RootCommand): command is "add" | "list" | "open" | "close" | "refresh" | "archive" | "unarchive" | "label" | "remove" | "merge" | "send" | "tab" | "prune" | "restore" {
170+
function isWorktreeCommand(command: RootCommand): command is "add" | "list" | "open" | "close" | "refresh" | "archive" | "unarchive" | "label" | "profile" | "remove" | "merge" | "send" | "tab" | "prune" | "restore" {
169171
return command === "add"
170172
|| command === "list"
171173
|| command === "open"
@@ -174,6 +176,7 @@ function isWorktreeCommand(command: RootCommand): command is "add" | "list" | "o
174176
|| command === "archive"
175177
|| command === "unarchive"
176178
|| command === "label"
179+
|| command === "profile"
177180
|| command === "remove"
178181
|| command === "merge"
179182
|| command === "send"

0 commit comments

Comments
 (0)