Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,9 @@ ade lane drift resolve --lane lane-id --switch-back # put the worktree ba
ade lane drift resolve --lane lane-id --keep-head # re-point the lane (and its name) at the live HEAD branch
ade lane drift resolve --lane lane-id --keep-head --expected-head hotfix-auth --force # --expected-head guards a stale read; --force acknowledges active work
ade lanes reparent lane-child --parent lane-parent --stack-base-branch main
ade lanes reclaim-preview lane-id --text # show reclaimable space and anything that needs review
ade lanes archive-and-reclaim lane-id --confirm RECLAIM # preserve lane history/branch/chat; remove ADE-managed local files
ade lanes unarchive lane-id # restore the lane; recreate its managed worktree when needed
ade lanes delete lane-id --force --delete-branch
ade lanes create-from-linear --issue-id ENG-431 --start-chat --provider codex --model <model>
ade lanes batch-create-from-linear --linear-issues-json '[{"id":"...","identifier":"ENG-431"},{"id":"...","identifier":"ENG-440"}]'
Expand Down
6 changes: 6 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { createLaneEnvironmentService } from "../../desktop/src/main/services/la
import { createLaneTemplateService } from "../../desktop/src/main/services/lanes/laneTemplateService";
import { createPortAllocationService } from "../../desktop/src/main/services/lanes/portAllocationService";
import { createLaneProxyService } from "../../desktop/src/main/services/lanes/laneProxyService";
import { releaseLaneRuntimeResources } from "../../desktop/src/main/services/lanes/laneRuntimeLifecycle";
import { createOAuthRedirectService } from "../../desktop/src/main/services/lanes/oauthRedirectService";
import { createRuntimeDiagnosticsService } from "../../desktop/src/main/services/lanes/runtimeDiagnosticsService";
import { createRebaseSuggestionService } from "../../desktop/src/main/services/lanes/rebaseSuggestionService";
Expand Down Expand Up @@ -1570,6 +1571,11 @@ export async function createAdeRuntime(args: {
|| ptyService.isTranscriptPathActive(filePath)
|| Boolean(iosSimulatorService?.isBuildPathActive(filePath)),
projectId,
laneService,
projectConfigService,
releaseLaneRuntimeResources: (laneId) => {
releaseLaneRuntimeResources({ portAllocationService, laneProxyService }, laneId);
},
// One bounded `ade_feature_used` per completed maintenance run at the daemon
// boundary (deduped to 20 h by the service).
captureAnalytics: (input) => {
Expand Down
56 changes: 56 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,62 @@ describe("ADE CLI", () => {
});
});

it("builds lane reclaim preview and confirmed reclaim commands", () => {
const preview = expectExecutePlan(
buildCliPlan(["lanes", "reclaim-preview", "lane-123"]),
);
expect(preview.label).toBe("lane reclaim preview");
expect(preview.steps).toEqual([
{
key: "result",
method: "ade/actions/call",
params: {
name: "run_ade_action",
arguments: {
domain: "lane",
action: "getReclaimRisk",
args: { laneId: "lane-123" },
},
},
unwrapToolResult: true,
},
]);

const reclaim = expectExecutePlan(
buildCliPlan([
"lanes",
"archive-and-reclaim",
"lane-123",
"--confirm",
"RECLAIM",
"--force-dirty",
]),
);
expect(reclaim.label).toBe("lane archive and reclaim");
expect(reclaim.steps).toEqual([
{
key: "result",
method: "ade/actions/call",
params: {
name: "run_ade_action",
arguments: {
domain: "lane",
action: "archiveAndReclaim",
args: {
laneId: "lane-123",
confirmation: "RECLAIM",
forceDirty: true,
},
},
},
unwrapToolResult: true,
},
]);
expect(() =>
buildCliPlan(["lanes", "archive-and-reclaim", "lane-123"]),
).toThrow(/--confirm RECLAIM/);
});

it("builds sync status and pairing PIN commands", () => {
const status = buildCliPlan([
"sync",
Expand Down
55 changes: 54 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1485,7 +1485,10 @@ const HELP_BY_COMMAND: Record<string, string> = {
Child lanes carry the parent's unmerged work
$ ade lanes import --branch <branch> Register an existing branch/worktree
$ ade lanes archive <lane> Archive a lane in ADE
$ ade lanes unarchive <lane> Restore an archived lane
$ ade lanes reclaim-preview <lane> Show reclaimable space and safety warnings
$ ade lanes archive-and-reclaim <lane> --confirm RECLAIM
Archive the lane, then remove its ADE-managed local files
$ ade lanes unarchive <lane> Restore an archived lane and recreate its worktree if needed
$ ade lanes delete <lane> --force Delete a lane and clean up its worktree
$ ade lanes attach --path <worktree> --name <n> Attach an external worktree
$ ade lanes reparent <lane> --parent <parent> Move lane onto a new parent (runs git rebase)
Expand Down Expand Up @@ -4177,6 +4180,56 @@ function buildLanePlan(args: string[]): CliPlan {
],
};
}
if (
sub === "reclaim-preview" ||
sub === "reclaim-risk" ||
sub === "preview-reclaim"
) {
const laneId = requireValue(
readLaneId(args) ?? firstPositional(args),
"laneId",
);
return {
kind: "execute",
label: "lane reclaim preview",
steps: [
actionStep(
"result",
"lane",
"getReclaimRisk",
collectGenericObjectArgs(args, { laneId }),
),
],
};
}
if (sub === "archive-and-reclaim" || sub === "reclaim") {
const laneId = requireValue(
readLaneId(args) ?? firstPositional(args),
"laneId",
);
const confirmation = readValue(args, ["--confirm", "--confirmation"]);
if (confirmation !== "RECLAIM") {
throw new CliUsageError(
'archive-and-reclaim requires --confirm RECLAIM. Run "ade lanes reclaim-preview <lane>" first.',
);
}
return {
kind: "execute",
label: "lane archive and reclaim",
steps: [
actionStep(
"result",
"lane",
"archiveAndReclaim",
collectGenericObjectArgs(args, {
laneId,
confirmation: "RECLAIM",
forceDirty: readFlag(args, ["--force-dirty"]),
}),
),
],
};
}
if (sub === "delete" || sub === "rm") {
const laneId = requireValue(
readLaneId(args) ?? firstPositional(args),
Expand Down
61 changes: 61 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ function createService(options?: {
isCloudRelayEnabled?: () => boolean;
linearCredentialService?: Record<string, unknown>;
linearOAuthService?: Record<string, unknown>;
laneEnvironmentService?: Record<string, unknown>;
portAllocationService?: Record<string, unknown>;
getLinearIssueTracker?: () => Record<string, unknown> | null;
usageTrackingService?: Record<string, unknown>;
productAnalyticsService?: Record<string, unknown>;
Expand Down Expand Up @@ -96,6 +98,8 @@ function createService(options?: {
...(options?.isCloudRelayEnabled ? { isCloudRelayEnabled: options.isCloudRelayEnabled } : {}),
...(options?.linearCredentialService ? { linearCredentialService: options.linearCredentialService } : {}),
...(options?.linearOAuthService ? { linearOAuthService: options.linearOAuthService } : {}),
...(options?.laneEnvironmentService ? { laneEnvironmentService: options.laneEnvironmentService } : {}),
...(options?.portAllocationService ? { portAllocationService: options.portAllocationService } : {}),
...(options?.getLinearIssueTracker ? { getLinearIssueTracker: options.getLinearIssueTracker } : {}),
...(options?.usageTrackingService ? { usageTrackingService: options.usageTrackingService } : {}),
...(options?.productAnalyticsService ? { productAnalyticsService: options.productAnalyticsService } : {}),
Expand Down Expand Up @@ -2468,6 +2472,63 @@ describe("lanes.suggestName", () => {
});
});

describe("lanes.unarchive", () => {
it("recreates the lane environment while preserving the mobile response", async () => {
const lane = {
id: "lane-1",
name: "Lane one",
laneType: "worktree",
worktreePath: "/repo/.ade/worktrees/lane-1",
};
const unarchive = vi.fn().mockResolvedValue({
lane,
worktreeRecreated: true,
});
const list = vi.fn().mockResolvedValue([lane]);
const envInitConfig = { dependencies: ["npm install"] };
const lease = {
laneId: "lane-1",
rangeStart: 4100,
rangeEnd: 4199,
status: "active",
};
const getLease = vi.fn().mockReturnValue(null);
const acquire = vi.fn().mockReturnValue(lease);
const getEffective = vi.fn().mockReturnValue({
laneEnvInit: null,
laneOverlayPolicies: [],
});
const resolveEnvInitConfig = vi.fn().mockReturnValue(envInitConfig);
const initLaneEnvironment = vi.fn().mockResolvedValue({ state: "ready" });
const { service } = createService({
laneService: { unarchive, list },
projectConfigService: {
getEffective,
},
laneEnvironmentService: {
resolveEnvInitConfig,
initLaneEnvironment,
},
portAllocationService: {
getLease,
acquire,
},
});

await expect(
service.execute(makePayload("lanes.unarchive", { laneId: "lane-1" })),
).resolves.toEqual({ ok: true });
expect(unarchive).toHaveBeenCalledWith({ laneId: "lane-1" });
expect(list).toHaveBeenCalledWith({ includeArchived: false, includeStatus: false });
expect(acquire).toHaveBeenCalledWith("lane-1");
expect(acquire.mock.invocationCallOrder[0]).toBeLessThan(getEffective.mock.invocationCallOrder[0]!);
expect(acquire.mock.invocationCallOrder[0]).toBeLessThan(resolveEnvInitConfig.mock.invocationCallOrder[0]!);
const overrides = { portRange: { start: 4100, end: 4199 } };
expect(resolveEnvInitConfig).toHaveBeenCalledWith(null, overrides);
expect(initLaneEnvironment).toHaveBeenCalledWith(lane, envInitConfig, overrides);
});
});

describe("lanes.refreshSnapshots conditional responses", () => {
function createLaneListService() {
const lanes = [{ id: "lane-1", name: "Lane one", status: { dirty: false, ahead: 0, behind: 0 } }];
Expand Down
29 changes: 25 additions & 4 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ import type { createGithubService } from "../../../../desktop/src/main/services/
import type { createOperationService } from "../../../../desktop/src/main/services/history/operationService";
import type { createAutoRebaseService } from "../../../../desktop/src/main/services/lanes/autoRebaseService";
import type { createLaneEnvironmentService } from "../../../../desktop/src/main/services/lanes/laneEnvironmentService";
import { restoreRecreatedLaneRuntime } from "../../../../desktop/src/main/services/lanes/laneRuntimeLifecycle";
import type { createLaneService } from "../../../../desktop/src/main/services/lanes/laneService";
import type { createLaneTemplateService } from "../../../../desktop/src/main/services/lanes/laneTemplateService";
import type { createPortAllocationService } from "../../../../desktop/src/main/services/lanes/portAllocationService";
Expand Down Expand Up @@ -3441,6 +3442,28 @@ async function deleteLaneWithRuntimeCleanup(
return { ok: true };
}

async function unarchiveLaneWithRuntimeSetup(
args: SyncRemoteCommandServiceArgs,
payload: Record<string, unknown>,
): Promise<{ ok: true }> {
const archiveArgs = parseArchiveLaneArgs(payload, "lanes.unarchive");
const result = await args.laneService.unarchive(archiveArgs);
if (!result.worktreeRecreated) {
return { ok: true };
}
try {
await restoreRecreatedLaneRuntime(args, archiveArgs.laneId);
} catch (error) {
// Keep the established mobile command response stable. The worktree was
// restored successfully; environment setup can be retried separately.
args.logger.warn("sync_remote.lane_env_setup.post_unarchive_failed", {
laneId: archiveArgs.laneId,
err: String(error),
});
}
return { ok: true };
}

async function resolveChatCreateArgs<T extends AgentChatCreateArgs>(
service: ReturnType<typeof createAgentChatService>,
payload: T,
Expand Down Expand Up @@ -3801,10 +3824,8 @@ function registerLaneRemoteCommands({ args, register }: RemoteCommandRegistratio
await args.laneService.archive(parseArchiveLaneArgs(payload, "lanes.archive"));
return { ok: true };
});
register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => {
await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive"));
return { ok: true };
});
register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) =>
unarchiveLaneWithRuntimeSetup(args, payload));
register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) =>
deleteLaneWithRuntimeCleanup(args, payload));
register("lanes.getStackChain", { viewerAllowed: true }, async (payload) =>
Expand Down
36 changes: 36 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
formatGoalBannerLine,
formatGitConflictReport,
formatLaneDeleteRisk,
formatLaneReclaimPreview,
formFieldUsesPromptInput,
isChatFlushEdge,
isChatSessionAnimating,
Expand Down Expand Up @@ -1563,6 +1564,41 @@ describe("formatLaneDeleteRisk", () => {
});
});

describe("formatLaneReclaimPreview", () => {
it("states what ADE removes, keeps, and requires before reclaiming dirty work", () => {
const preview = formatLaneReclaimPreview({
laneId: "lane-1",
laneName: "Feature lane",
branchRef: "feat/x",
worktreePath: "/project/.ade/worktrees/feature",
dirty: true,
hasUnpushedCommits: false,
unpushedCommitCount: 0,
remoteBranchExists: false,
activeChatCount: 0,
activePtyCount: 0,
activeWatcherCount: 0,
envInitialized: false,
worktreeBytes: 1024 ** 3,
generatedBytes: 256 * 1024 ** 2,
reclaimableBytes: 1.25 * 1024 ** 3,
worktreeAvailable: true,
blockedReasons: [{
code: "dirty_worktree",
message: "This lane has uncommitted files.",
disposition: "confirmation_required",
}],
lastFailure: null,
retryCount: 0,
});

expect(preview).toContain("Estimated space: 1.3 GB");
expect(preview).toContain("Keeps: the lane, branch, chats, and metadata.");
expect(preview).toContain("/lane archive-and-reclaim lane-1 RECLAIM force-dirty");
expect(preview).toContain("Nothing has been removed.");
});
});

describe("model picker escape handling", () => {
const picker = {
kind: "model-picker" as const,
Expand Down
18 changes: 17 additions & 1 deletion apps/ade-cli/src/tuiClient/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,26 @@ describe("commands", () => {
expect(unarchive?.name).toBe("/lane unarchive");
expect(unarchive?.args).toBe("feat/x");

const preview = parseCommand("/lane reclaim-preview feat/x");
expect(preview?.name).toBe("/lane reclaim-preview");
expect(preview?.args).toBe("feat/x");

const reclaim = parseCommand("/lane archive-and-reclaim feat/x RECLAIM");
expect(reclaim?.name).toBe("/lane archive-and-reclaim");
expect(reclaim?.args).toBe("feat/x RECLAIM");

// /lane delete must still match (longest-name-first ordering).
expect(parseCommand("/lane delete")?.name).toBe("/lane delete");
expect(paletteCommands("/lane").map((c) => c.name)).toEqual(
expect.arrayContaining(["/lane rename", "/lane archive", "/lane unarchive", "/lane archived", "/lane delete"]),
expect.arrayContaining([
"/lane rename",
"/lane archive",
"/lane reclaim-preview",
"/lane archive-and-reclaim",
"/lane unarchive",
"/lane archived",
"/lane delete",
]),
);
});

Expand Down
Loading