diff --git a/.changeset/prune-orphaned-worktree-snapshots.md b/.changeset/prune-orphaned-worktree-snapshots.md new file mode 100644 index 0000000000..b9926e7622 --- /dev/null +++ b/.changeset/prune-orphaned-worktree-snapshots.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Remove deleted worktree checkpoints without losing conversation history and stop showing activity for deleted sessions. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 1c1450bebc..520c484b70 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -379,6 +379,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private promptRecoveryQueued = false private promptRecovery: Promise | null = null private trackedSessionIds: Set = new Set() + private readonly removedSessionIds = new Set() private readonly openSessionIds = new Set() private modelUsageSessionIds: Set = new Set() private syncedChildSessions: Set = new Set() @@ -801,6 +802,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Register a session created externally and notify the webview. */ public registerSession(session: Session, activate = false): void { + this.removedSessionIds.delete(session.id) this.stopCurrentSessionProcesses(session.id) this.setCurrentSession(session) this.contextSessionID = session.id @@ -2340,6 +2342,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * a session the backend has already deleted. */ private pruneDeletedSession(sessionID: string): void { + this.removedSessionIds.add(sessionID) this.trackedSessionIds.delete(sessionID) this.openSessionIds.delete(sessionID) for (const [key, session] of this.draftSessions) { @@ -4724,6 +4727,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // busy-session warning on Save. if (event.type === "session.status") { const sid = event.properties.sessionID + if (this.removedSessionIds.has(sid)) return const status = event.properties.status this.mark(sid, directory) this.aborts.observe(sid, status.type, directory) @@ -5440,6 +5444,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.promptRecoveryQueued = false clearNetworkWaits(this.trackedSessionIds) this.trackedSessionIds.clear() + this.removedSessionIds.clear() this.openSessionIds.clear() this.syncedChildSessions.clear() this.inspectorSessionIds.clear() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 37449eac19..f4af07bf98 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -118,6 +118,7 @@ export class AgentManagerProvider implements Disposable { private onVisibilityChange: ((visible: boolean) => void) | undefined private panelSessions = new Set() private busySessions = new Set() + private removedSessions = new Set() readonly settings: ProjectWiring["settings"] /** Session ID most recently loaded via `loadMessages`; updated synchronously. */ private activeSessionId: string | undefined @@ -308,6 +309,7 @@ export class AgentManagerProvider implements Disposable { if (ev.type === "session.deleted") { const id = ev.properties?.sessionID if (!id) return + this.removedSessions.add(id) this.busySessions.delete(id) const ctx = this.contexts.byLiveSession(id) if (!ctx) return @@ -316,7 +318,8 @@ export class AgentManagerProvider implements Disposable { return } const info = ev.properties?.info - const dir = info?.directory + if (ev.type === "session.created" && info) this.removedSessions.delete(info.id) + const dir = info && !this.removedSessions.has(info.id) ? info.directory : undefined // Session events from sync or older backends can lack time/directory; a throw // would escape into the SSE dispatch loop and starve the other listeners. if (!info?.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return @@ -332,12 +335,11 @@ export class AgentManagerProvider implements Disposable { ctx.invalidateSessions() this.postToWebview({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] }) } - private onSessionStatus(event: unknown): void { const props = (event as { properties?: { sessionID?: string; status?: { type?: string } } }).properties const sid = props?.sessionID const type = props?.status?.type - if (!sid || !type) return + if (!sid || !type || this.removedSessions.has(sid)) return if (type === "idle") { this.busySessions.delete(sid) this.naming.idle(sid) @@ -346,12 +348,10 @@ export class AgentManagerProvider implements Disposable { this.busySessions.add(sid) this.naming.busy(sid) } - private log(...args: unknown[]) { const msg = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ") this.outputChannel.appendLine(`${new Date().toISOString()} ${msg}`) } - public openPanel(preserveFocus?: boolean): void { if (this.panel) { this.log("Panel already open, revealing") @@ -1123,7 +1123,6 @@ export class AgentManagerProvider implements Disposable { req, ) } - // Worktree actions /** Create a new worktree with an auto-created first session. */ @@ -1452,9 +1451,6 @@ export class AgentManagerProvider implements Disposable { runScriptConfigured: false, }) } - - // Manager accessors — repository-bound services are owned by the active ProjectContext (immutable per root). - /** Provider capabilities for the worktree lifecycle handlers (state stays in ProjectContext). */ private get lifecycleHost(): LifecycleHost { return { createOnDisk: (opts) => this.createWorktreeOnDisk(opts), @@ -1464,6 +1460,8 @@ export class AgentManagerProvider implements Disposable { sessions: { register: (session) => this.panel?.sessions.registerSession(session), clearDirectory: (sid) => this.panel?.sessions.clearSessionDirectory(sid), + setSessionDirectory: (sid, dir) => this.panel?.sessions.setSessionDirectory(sid, dir), + registerSessionRoute: (ref, dir, gen) => this.panel?.sessions.registerSessionRoute?.(ref, dir, gen), directories: () => this.panel?.sessions.getSessionDirectories(), abort: (ids) => this.panel?.sessions.abortSessions(ids) ?? Promise.resolve(), forget: (sid) => void this.panelSessions.delete(sid), @@ -1485,6 +1483,7 @@ export class AgentManagerProvider implements Disposable { acquirePtyCleanup: (directory) => this.acquirePtyCleanup(directory), metadata: (client, dir) => sandboxSessionMetadata(this.connectionService.sandboxPreference, client, dir), post: (msg) => this.postToWebview(msg), + notify: (message) => this.host.showError(message), log: (...args) => this.log(...args), } } diff --git a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts index 3d53cf77a7..970c4734ae 100644 --- a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts +++ b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts @@ -9,6 +9,7 @@ import type { CreateWorktreeResult, WorktreeManager } from "./WorktreeManager" import type { CreateWorktreeOnDiskOptions, CreateWorktreeOnDiskResult } from "./worktree-create" import { recordPromotionHandoff } from "./promotion-handoff" import { stopSessionProcesses } from "../kilo-provider/background-process" +import { routeProjectSession } from "./project/messages" /** * Provider capabilities the worktree lifecycle needs beyond project state. @@ -25,6 +26,12 @@ export interface LifecycleHost { sessions: { register: (session: Session) => void clearDirectory: (sessionId: string) => void + setSessionDirectory: (sessionId: string, directory: string) => void + registerSessionRoute?: ( + ref: { projectId: string; sessionId: string }, + directory: string, + generation: number, + ) => void directories: () => ReadonlyMap | undefined abort: (sessionIds: string[]) => Promise forget: (sessionId: string) => void @@ -45,6 +52,7 @@ export interface LifecycleHost { acquirePtyCleanup: (directory: string) => Promise<() => void> metadata: (client: KiloClient, dir: string) => Promise> post: (message: AgentManagerOutMessage) => void + notify: (message: string) => void log: (...args: unknown[]) => void } @@ -114,14 +122,55 @@ export async function deleteLifecycleWorktree( host.log(`Worktree ${worktreeId} not found in state`) return null } + const fail = (message: string) => { + host.post({ type: "error", code: "agentManager.worktreeDeleteFailed", projectId: ctx.id, worktreeId, message }) + return null + } + const retained = new Set(state.getSessions(worktreeId).map((session) => session.id)) + let client: KiloClient + try { + client = host.client() + const [status, permissions, questions, sessions] = await Promise.all([ + client.session.status({ directory: worktree.path }, { throwOnError: true }), + client.permission.list({ directory: worktree.path }, { throwOnError: true }), + client.question.list({ directory: worktree.path }, { throwOnError: true }), + client.experimental.session.list( + { directory: worktree.path, archived: true, roots: false, limit: Number.MAX_SAFE_INTEGER }, + { throwOnError: true }, + ), + ]) + if ( + status.data === undefined || + permissions.data === undefined || + questions.data === undefined || + sessions.data === undefined + ) + throw new Error("Deletion safety checks returned no data") + sessions.data.forEach((session) => retained.add(session.id)) + const active = Object.values(status.data).some((value) => value.type !== "idle") + if (active || permissions.data.length > 0 || questions.data.length > 0) + return fail("Cannot delete a worktree while a session is active or waiting for input") + } catch (error) { + host.log(`Failed to verify worktree deletion safety: ${error}`) + return fail("Cannot verify worktree sessions before deletion") + } // Stop pollers before cleanup. State is removed only after PTYs and disk are gone so a failed // process cleanup cannot leave a live shell rooted in an untracked worktree. - host.skipStats(worktreeId) - await host.removeRun(worktreeId) - if (!(await host.clearRun(worktreeId))) { + try { + host.skipStats(worktreeId) + await host.removeRun(worktreeId) + } catch (error) { host.unskipStats(worktreeId) - host.post({ type: "error", message: "Failed to stop the Run script before deleting the worktree" }) - return null + host.log(`Failed to stop worktree services: ${error}`) + return fail("Failed to stop worktree services before deletion") + } + const cleared = await host.clearRun(worktreeId).catch((error) => { + host.log(`Failed to stop the Run script: ${error}`) + return false + }) + if (!cleared) { + host.unskipStats(worktreeId) + return fail("Failed to stop the Run script before deleting the worktree") } const branch = worktree.branchOwned === false ? undefined : (worktree.originalBranch ?? worktree.branch) let releasePtyCleanup: () => void @@ -130,17 +179,37 @@ export async function deleteLifecycleWorktree( } catch (error) { host.log(`Failed to remove worktree from disk: ${error}`) host.unskipStats(worktreeId) - return null + return fail("Failed to remove worktree PTYs before deletion") } try { await ctx.worktreeManager().removeWorktree(worktree.path, branch) + await Promise.all( + [...retained].map((sessionID) => + client.experimental.controlPlane.moveSession( + { sessionID, destination: { directory: ctx.root }, moveChanges: false }, + { throwOnError: true }, + ), + ), + ) + try { + await client.kilocode.removeSnapshot({ directory: ctx.root, worktree: worktree.path }, { throwOnError: true }) + } catch (error) { + host.log(`Failed to remove worktree snapshots: ${error}`) + host.notify( + "The worktree was deleted, but its checkpoint data could not be removed. Conversation history is preserved.", + ) + } const orphaned = state.removeWorktree(worktreeId) host.removePR(worktreeId) host.forgetName(worktreeId) host.stopDiffs(worktree.path, orphaned) - for (const s of orphaned) host.sessions.clearDirectory(s.id) + for (const sessionID of retained) routeProjectSession(host.sessions, ctx.id, sessionID, ctx.root, ctx.generation) host.push() host.log(`Deleted worktree ${worktreeId}${branch ? ` (${branch})` : ""}`) + } catch (error) { + host.unskipStats(worktreeId) + host.log(`Failed to delete worktree ${worktreeId}: ${error}`) + return fail("Failed to delete the worktree") } finally { releasePtyCleanup() } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index b10678574b..ba7ac6d4dd 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -263,6 +263,9 @@ interface ScriptTerminalsMessage { interface ErrorOutMessage { type: "error" message: string + code?: string + projectId?: string + worktreeId?: string } interface SessionAddedMessage { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index c6536f025a..e8d570ce4f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -637,12 +637,23 @@ describe("Agent Manager Provider — onMessage routing", () => { * Regression: deletion must clean up both disk (manager) and state, then * push to webview. Missing any step leaves ghost worktrees or stale UI. */ - it("onDeleteWorktree removes from disk, state, clears orphans, and pushes", () => { + it("does not restore running indicators after a session is deleted", () => { + const lifecycle = body("onSessionLifecycle") + const status = body("onSessionStatus") + + expect(lifecycle).toContain("this.removedSessions.add(id)") + expect(lifecycle).toContain("this.busySessions.delete(id)") + expect(lifecycle).toContain("info && !this.removedSessions.has(info.id) ? info.directory : undefined") + expect(status).toContain("this.removedSessions.has(sid)") + }) + + it("limits snapshot cleanup to explicit worktree deletion without deleting sessions", () => { const text = body("onDeleteWorktree") - expect(text).toContain("worktreeManager().removeWorktree") - expect(text).toContain("state.removeWorktree") - expect(text).toContain("sessions.clearDirectory") - expect(text).toContain("host.push()") + expect(text).toContain(".kilocode.removeSnapshot") + expect(text).not.toContain("session.delete") + for (const name of ["onCreateWorktree", "onCreateMultiVersion", "onRemoveStaleWorktree"]) { + expect(body(name)).not.toContain("removeSnapshot") + } }) // -- onCreateWorktree invariants ------------------------------------------- diff --git a/packages/kilo-vscode/tests/unit/agent-manager-provider-lifecycle.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-provider-lifecycle.test.ts new file mode 100644 index 0000000000..7cb0142e83 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-provider-lifecycle.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" +import { ProjectContext } from "../../src/agent-manager/project/context" +import { deleteLifecycleWorktree, type LifecycleHost } from "../../src/agent-manager/provider-lifecycle" +import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +describe("Agent Manager worktree deletion lifecycle", () => { + let root: string + let worktree: string + let state: WorktreeStateManager + let ctx: ProjectContext + let calls: string[] + let routes: Array<{ sessionID: string; directory: string; projectID: string; generation: number }> + let client: { + session: { status: ReturnType; delete: ReturnType } + permission: { list: ReturnType } + question: { list: ReturnType } + experimental: { session: { list: ReturnType }; controlPlane: { moveSession: ReturnType } } + kilocode: { removeSnapshot: ReturnType } + } + let host: LifecycleHost + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "am-delete-lifecycle-")) + worktree = path.join(root, "worktree") + fs.mkdirSync(path.join(root, ".kilo"), { recursive: true }) + fs.mkdirSync(worktree) + calls = [] + routes = [] + state = new WorktreeStateManager(root, () => undefined) + ctx = new ProjectContext("project", root, true, { + log: () => undefined, + state: () => state, + worktrees: () => + ({ + removeWorktree: mock(async () => calls.push("disk")), + }) as never, + }) + ctx.stateManager().addWorktree({ branch: "feature", path: worktree, parentBranch: "main" }) + client = { + session: { + status: mock(async () => ({ data: {} as Record })), + delete: mock(async () => ({ data: true })), + }, + permission: { list: mock(async () => ({ data: [] })) }, + question: { list: mock(async () => ({ data: [] })) }, + experimental: { + session: { + list: mock(async () => ({ + data: state.getSessions().map((session) => ({ id: session.id, directory: worktree })), + })), + }, + controlPlane: { + moveSession: mock(async ({ sessionID }: { sessionID: string }) => { + calls.push(`move:${sessionID}`) + }), + }, + }, + kilocode: { + removeSnapshot: mock(async () => { + calls.push("snapshots") + return { data: true } + }), + }, + } + host = { + createOnDisk: async () => null, + runSetup: async () => undefined, + createSession: async () => null, + notifyReady: () => undefined, + sessions: { + register: () => undefined, + clearDirectory: (id) => calls.push(`clear:${id}`), + setSessionDirectory: (id, directory) => calls.push(`directory:${id}:${directory}`), + registerSessionRoute: (ref, directory, generation) => + routes.push({ sessionID: ref.sessionId, projectID: ref.projectId, directory, generation }), + directories: () => new Map(), + abort: async () => undefined, + forget: () => undefined, + }, + push: () => calls.push("push"), + register: () => undefined, + skipStats: () => calls.push("stats:skip"), + unskipStats: () => calls.push("stats:unskip"), + removePR: () => calls.push("pr"), + removeRun: async () => calls.push("run:remove"), + clearRun: async () => { + calls.push("run:clear") + return true + }, + forgetName: () => calls.push("name"), + stopDiffs: () => calls.push("diff"), + capture: () => undefined, + autoName: () => ({ enabled: false }), + client: () => client as unknown as KiloClient, + acquirePtyCleanup: async () => { + calls.push("pty") + return () => calls.push("pty:release") + }, + metadata: async () => ({}), + post: (message) => calls.push(`post:${message.type}`), + notify: (message) => calls.push(`notify:${message}`), + log: () => undefined, + } + }) + + afterEach(async () => { + await state.flush() + fs.rmSync(root, { recursive: true, force: true }) + }) + + const deleteWorktree = async () => deleteLifecycleWorktree(ctx, host, state.getWorktrees()[0]!.id) + + it.each([ + ["busy", { type: "busy" }], + ["retry", { type: "retry", attempt: 1, message: "retry", next: 100 }], + ["offline", { type: "offline", requestID: "req", message: "offline" }], + ] as const)("refuses a %s session before cleanup", async (_name, status) => { + const session = state.addSession("session", state.getWorktrees()[0]!.id) + client.session.status.mockResolvedValue({ data: { [session.id]: status } }) + + await deleteWorktree() + + expect(calls).toEqual(["post:error"]) + expect(state.getWorktree(session.worktreeId!)).toBeDefined() + expect(client.session.status).toHaveBeenCalledWith({ directory: worktree }, { throwOnError: true }) + expect(client.permission.list).toHaveBeenCalledWith({ directory: worktree }, { throwOnError: true }) + expect(client.question.list).toHaveBeenCalledWith({ directory: worktree }, { throwOnError: true }) + }) + + it.each(["permission", "question"] as const)("refuses a pending %s before cleanup", async (kind) => { + const session = state.addSession("session", state.getWorktrees()[0]!.id) + const list = kind === "permission" ? client.permission.list : client.question.list + list.mockResolvedValue({ data: [{ id: kind, sessionID: session.id }] }) + + await deleteWorktree() + + expect(calls).toEqual(["post:error"]) + expect(state.getWorktree(session.worktreeId!)).toBeDefined() + }) + + it("fails closed before cleanup when an authoritative check fails", async () => { + client.question.list.mockRejectedValue(new Error("backend unavailable")) + + await deleteWorktree() + + expect(calls).toEqual(["post:error"]) + expect(state.getWorktrees()).toHaveLength(1) + }) + + it.each(["removeRun", "clearRun", "acquirePtyCleanup"] as const)( + "reports a %s failure without removing the worktree or checkpoints", + async (method) => { + const id = state.getWorktrees()[0]!.id + const post = mock(host.post) + host.post = post + host[method] = mock(async () => { + throw new Error("cleanup failed") + }) + + await deleteWorktree() + + expect(post).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + code: "agentManager.worktreeDeleteFailed", + projectId: ctx.id, + worktreeId: id, + }), + ) + expect(calls).toContain("stats:unskip") + expect(calls).not.toContain("disk") + expect(client.kilocode.removeSnapshot).not.toHaveBeenCalled() + expect(state.getWorktrees()).toHaveLength(1) + }, + ) + + it("reports checkpoint cleanup failures while preserving and retargeting sessions", async () => { + const session = state.addSession("retained", state.getWorktrees()[0]!.id) + const notify = mock(host.notify) + host.notify = notify + client.kilocode.removeSnapshot.mockRejectedValue(new Error("checkpoint cleanup failed")) + + await deleteWorktree() + + expect(notify).toHaveBeenCalledWith( + "The worktree was deleted, but its checkpoint data could not be removed. Conversation history is preserved.", + ) + expect(state.getWorktrees()).toHaveLength(0) + expect(routes).toContainEqual({ + sessionID: session.id, + projectID: ctx.id, + directory: ctx.root, + generation: ctx.generation, + }) + expect(client.session.delete).not.toHaveBeenCalled() + }) + + it("relocates archived and child sessions not present in Agent Manager state", async () => { + client.experimental.session.list.mockResolvedValue({ + data: [ + { id: "archived", directory: worktree, time: { archived: 1 } }, + { id: "child", directory: worktree, parentID: "parent" }, + ], + }) + + await deleteWorktree() + + expect(routes.map((route) => route.sessionID)).toEqual(["archived", "child"]) + expect(client.experimental.controlPlane.moveSession).toHaveBeenCalledTimes(2) + expect(client.session.delete).not.toHaveBeenCalled() + }) + + it("does not discard checkpoints when persistent session relocation fails", async () => { + state.addSession("retained", state.getWorktrees()[0]!.id) + client.experimental.controlPlane.moveSession.mockRejectedValue(new Error("move failed")) + + await deleteWorktree() + + expect(calls).toContain("disk") + expect(calls).toContain("post:error") + expect(client.kilocode.removeSnapshot).not.toHaveBeenCalled() + expect(state.getWorktrees()).toHaveLength(1) + expect(client.session.delete).not.toHaveBeenCalled() + }) + + it("retargets orphaned sessions to the exact project root without deleting them", async () => { + const first = state.addSession("first", state.getWorktrees()[0]!.id) + const second = state.addSession("second", state.getWorktrees()[0]!.id) + + await deleteWorktree() + + expect(routes).toEqual([ + { sessionID: first.id, projectID: ctx.id, directory: ctx.root, generation: ctx.generation }, + { sessionID: second.id, projectID: ctx.id, directory: ctx.root, generation: ctx.generation }, + ]) + expect(calls).not.toContain(`clear:${first.id}`) + expect(calls).not.toContain(`clear:${second.id}`) + expect(client.session.delete).not.toHaveBeenCalled() + expect(client.experimental.session.list).toHaveBeenCalledWith( + { directory: worktree, archived: true, roots: false, limit: Number.MAX_SAFE_INTEGER }, + { throwOnError: true }, + ) + for (const session of [first, second]) { + expect(client.experimental.controlPlane.moveSession).toHaveBeenCalledWith( + { sessionID: session.id, destination: { directory: ctx.root }, moveChanges: false }, + { throwOnError: true }, + ) + expect(calls.indexOf(`move:${session.id}`)).toBeGreaterThan(calls.indexOf("disk")) + expect(calls.indexOf(`move:${session.id}`)).toBeLessThan(calls.indexOf("snapshots")) + } + expect(client.kilocode.removeSnapshot).toHaveBeenCalledWith( + { directory: ctx.root, worktree }, + { throwOnError: true }, + ) + expect(state.getWorktrees()).toHaveLength(0) + expect(state.getSessions()).toHaveLength(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-project-progress.test.ts b/packages/kilo-vscode/tests/unit/agent-project-progress.test.ts index 0caddba5c5..e4906e9879 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-progress.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-progress.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test" import { createProjectStore } from "../../webview-ui/agent-manager/project/store" -import { clearMultiVersionBusy, markMultiVersionBusy } from "../../webview-ui/agent-manager/project/progress" +import { + clearFailedDelete, + clearMultiVersionBusy, + markMultiVersionBusy, +} from "../../webview-ui/agent-manager/project/progress" +import { createProjectRegistry } from "../../webview-ui/agent-manager/project/registry" const state = (projectId: string) => ({ type: "agentManager.state" as const, @@ -20,6 +25,31 @@ const state = (projectId: string) => ({ }) describe("multi-project progress state", () => { + it.each([undefined, "b"])("clears failed deletion only for the resolved project %s", (projectId) => { + const registry = createProjectRegistry({ persisted: {}, activeId: () => "a" }) + for (const id of ["a", "b"]) { + registry.ensure(id).setBusy( + new Map([ + ["same", { reason: "deleting" as const }], + ["other", { reason: "deleting" as const }], + ]), + ) + } + + const store = registry.ensure(projectId ?? "a") + const peer = registry.ensure(projectId ? "a" : "b") + clearFailedDelete({ type: "error", message: "failed", code: "unrelated", projectId, worktreeId: "same" }, registry) + expect(store.busy().has("same")).toBe(true) + clearFailedDelete( + { type: "error", message: "failed", code: "agentManager.worktreeDeleteFailed", projectId, worktreeId: "same" }, + registry, + ) + + expect(store.busy().has("same")).toBe(false) + expect(peer.busy().has("same")).toBe(true) + expect(store.busy().has("other")).toBe(true) + }) + it("updates only the owning project's grouped worktrees", () => { const first = createProjectStore("a") const second = createProjectStore("b") diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index d862cbf4f9..e18a92e337 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -226,6 +226,7 @@ type ProviderInternals = { sessionDirectories: Map sessionStatusMap: Map trackedSessionIds: Set + removedSessionIds: Set openSessionIds: Set draftSessions: Map checkpoints: Map> @@ -1136,6 +1137,26 @@ describe("KiloProvider.handleDeleteSession / background processes", () => { expect(client.stopped).toEqual([{ sessionID: "s1", directory: "/repo/worktree" }]) }) + + it("ignores late activity updates after a session is deleted", async () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + const event = { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + } + + internal.handleEvent(event, "/repo") + expect(internal.sessionStatusMap.get("s1")).toBe("busy") + await internal.handleDeleteSession("s1") + const count = sent.length + + internal.handleEvent(event, "/repo") + + expect(internal.removedSessionIds.has("s1")).toBe(true) + expect(internal.sessionStatusMap.has("s1")).toBe(false) + expect(sent).toHaveLength(count) + }) }) describe("KiloProvider.handleLoadMessages / slim payload", () => { diff --git a/packages/kilo-vscode/tests/unit/project-session-busy.test.ts b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts index 0d85498fe9..53515eb2dd 100644 --- a/packages/kilo-vscode/tests/unit/project-session-busy.test.ts +++ b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts @@ -49,6 +49,8 @@ describe("createSessionBusy", () => { expect(state.agent("wt-working")).toBe(false) expect(state.session("working")).toBe(false) expect(state.project("background", "wt-unknown")).toBe(false) + expect(state.agent("wt-working", true)).toBe(true) + expect(state.project("background", "wt-unknown", true)).toBe(true) }) }) @@ -87,4 +89,27 @@ describe("createWorktreeBusy", () => { expect(state.project("background", "wt-idle")).toBe(false) expect(state.agent("wt-working")).toBe(true) }) + + it.each(["permission", "question", "non-blocking question"] as const)( + "blocks deletion for a pending %s without showing a running spinner", + (kind) => { + const state = createWorktreeBusy({ + statuses: () => ({ session: { type: "idle" } }), + permissions: () => (kind === "permission" ? [{ sessionID: "session" }] : []), + questions: () => (kind !== "permission" ? [{ sessionID: "session", blocking: kind === "question" }] : []), + worktrees: () => [], + subscribe: () => () => undefined, + managed: () => [{ id: "session", worktreeId: "worktree" }], + local: () => [], + projects: () => ({ other: [{ id: "session", worktreeId: "worktree" }] }), + active: () => "active", + }) + + expect(state.agent("worktree")).toBe(false) + expect(state.agent("worktree", true)).toBe(true) + expect(state.project("active", "worktree", true)).toBe(true) + expect(state.project("other", "worktree")).toBe(false) + expect(state.project("other", "worktree", true)).toBe(true) + }, + ) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 7bf70651b9..17a9093f48 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -230,6 +230,18 @@ describe("handleSessionDeleted draft cleanup contract", () => { const body = extractFunctionBody(source, "handleSessionDeleted") expect(body).toContain("setRespondingPermissions") }) + + it("prevents late status and attention events from reviving a deleted session", () => { + expect(extractFunctionBody(source, "handleSessionDeleted")).toContain("removedSessions.add(sessionID)") + expect(extractFunctionBody(source, "handleSessionStatus")).toContain("removedSessions.has(sessionID)") + expect(extractFunctionBody(source, "handlePermissionRequest")).toContain( + "removedSessions.has(permission.sessionID)", + ) + expect(extractFunctionBody(source, "handleQuestionRequest")).toContain("removedSessions.has(question.sessionID)") + expect(extractFunctionBody(source, "handleSuggestionRequest")).toContain( + "removedSessions.has(suggestion.sessionID)", + ) + }) }) describe("KiloProvider pruneDeletedSession contract", () => { @@ -243,7 +255,9 @@ describe("KiloProvider pruneDeletedSession contract", () => { // warning for the new current session. const match = source.match(/pruneDeletedSession\(sessionID: string\): void \{([\s\S]*?)\n \}/) expect(match).not.toBeNull() + expect(match![1]).toContain("this.removedSessionIds.add(sessionID)") expect(match![1]).toContain("this.sessionStatusMap.delete(sessionID)") + expect(source).toContain("if (this.removedSessionIds.has(sid)) return") }) it("clears currentSession and contextSessionID when the deleted id matches", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index e40c4c99b7..5e9287906b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -104,7 +104,7 @@ import { setReviewOpen, } from "./project/review-state" import { applyRunStatus } from "./project/run-status" -import { clearMultiVersionBusy, markMultiVersionBusy } from "./project/progress" +import { clearFailedDelete, clearMultiVersionBusy, markMultiVersionBusy } from "./project/progress" import { createSessionRestore, createTabMemory, @@ -1388,6 +1388,7 @@ const AgentManagerContent: Component = () => { }) const unsub = vscode.onMessage((msg) => { + clearFailedDelete(msg, registry) if (msg.type === "agentManager.repoInfo") { const info = msg as AgentManagerRepoInfoMessage setRepoBranch(info.branch) @@ -1822,8 +1823,8 @@ const AgentManagerContent: Component = () => { const confirmDeleteWorktree = (worktreeId: string) => { const wt = worktrees().find((w) => w.id === worktreeId) - if (!wt) return - + const run = runStatuses()[worktreeId]?.state + if (!wt || busyWorktrees().has(worktreeId) || isAgentBusy(worktreeId, true) || (run && run !== "idle")) return // Second press/click: execute the delete if (pendingDelete() === worktreeId) { cancelPendingDelete() @@ -2321,7 +2322,7 @@ const AgentManagerContent: Component = () => { states={projectStates()} store={(id) => registry.ensure(id)} busy={(projectId, id) => registry.ensure(projectId).busy().has(id)} - working={(projectId, id) => projectBusy(projectId, id)} + working={(projectId, id, waiting) => projectBusy(projectId, id, waiting)} localBusy={(projectId) => projectBusy(projectId, null)} stats={projectLive.stats()} local={projectLive.local()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index ee2c86e9be..2de694b5ed 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -41,7 +41,7 @@ interface Props { defaultBase?: (projectId: string) => string | undefined onCreate?: (projectId: string) => void busy?: (projectId: string, id: string) => boolean - working?: (projectId: string, id: string) => boolean + working?: (projectId: string, id: string, waiting?: boolean) => boolean localBusy?: (projectId: string) => boolean bindings: Record t: LanguageContextValue["t"] @@ -217,7 +217,7 @@ export const ProjectList: Component = (props) => { state={props.states[project.id]} store={props.store?.(project.id)} busy={(id) => props.busy?.(project.id, id) ?? false} - working={(id) => props.working?.(project.id, id) ?? false} + working={(id, waiting) => props.working?.(project.id, id, waiting) ?? false} localBusy={() => props.localBusy?.(project.id) ?? false} stats={props.stats[project.id]} local={props.local[project.id]} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index 52741e1440..1c7ad15ac8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -41,7 +41,7 @@ interface Props { state?: AgentManagerStateMessage store?: ProjectStore busy?: (id: string) => boolean - working?: (id: string) => boolean + working?: (id: string, waiting?: boolean) => boolean localBusy?: () => boolean stats?: Record local?: LocalGitStats @@ -81,6 +81,7 @@ export const ProjectSidebarBody: Component = (props) => { onCleanup(() => clearTimeout(pendingTimer)) /** Arm on the first click, execute on the second, matching the legacy sidebar. */ const confirmDelete = (worktreeId: string) => { + if (props.busy?.(worktreeId) || props.working?.(worktreeId, true)) return if (pending() === worktreeId) { clearTimeout(pendingTimer) setPending(undefined) @@ -238,6 +239,7 @@ export const ProjectSidebarBody: Component = (props) => { pendingDelete={pending() === worktree.id} busy={props.busy?.(worktree.id) ?? false} working={props.working?.(worktree.id) || runs()[worktree.id]?.state === "running"} + blocked={props.working?.(worktree.id, true)} stale={state()?.staleWorktreeIds?.includes(worktree.id) === true} stats={props.stats?.[worktree.id]} shortcut={values().shortcut} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx index 5e5e8ded6d..bc3a3bcd04 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -80,7 +80,7 @@ export interface SidebarBodyProps { worktreeSubtitle: (wt: WorktreeState) => string | undefined pendingDelete: () => string | null busy: (id: string) => boolean - isAgentBusy: (id: string) => boolean + isAgentBusy: (id: string, waiting?: boolean) => boolean isStaleWorktree: (id: string) => boolean shortcutMap: () => Map worktreeStats: () => Record @@ -315,6 +315,7 @@ export const SidebarBody: Component = (props) => { pendingDelete={props.pendingDelete() === wt.id} busy={props.busy(wt.id)} working={props.isAgentBusy(wt.id)} + blocked={props.isAgentBusy(wt.id, true)} stale={props.isStaleWorktree(wt.id)} shortcut={props.shortcutMap().get(wt.id)} stats={props.worktreeStats()[wt.id]} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index c44b767cff..e48404ffca 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -33,6 +33,7 @@ interface WorktreeItemProps { busy: boolean /** Whether an agent session on this worktree is actively working (shows spinner instead of branch icon). */ working: boolean + blocked?: boolean stale: boolean /** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0, >9, or undefined to hide. */ shortcut?: number @@ -303,7 +304,7 @@ export const WorktreeItem: Component = (props) => { {props.shortcut} - +
setOverClose(true)} @@ -520,17 +521,19 @@ export const WorktreeItem: Component = (props) => { {t("agentManager.worktree.rename")} - props.onDelete(new MouseEvent("click"))}> - - {t("agentManager.worktree.delete")} - - - {parseBindingTokens(props.closeKeybind).map((token) => ( - {token} - ))} - - - + + props.onDelete(new MouseEvent("click"))}> + + {t("agentManager.worktree.delete")} + + + {parseBindingTokens(props.closeKeybind).map((token) => ( + {token} + ))} + + + + props.onOpen()}> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts index 0a626885c7..02b22cc01f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/progress.ts @@ -1,4 +1,14 @@ import type { ProjectStore } from "./store" +import type { ExtensionMessage } from "../../src/types/messages" + +export function clearFailedDelete( + msg: ExtensionMessage, + stores: { ensure: (id: string) => ProjectStore; active: () => ProjectStore }, +): void { + if (msg.type !== "error" || msg.code !== "agentManager.worktreeDeleteFailed" || !msg.worktreeId) return + const store = msg.projectId ? stores.ensure(msg.projectId) : stores.active() + store.setBusy((prev) => new Map([...prev].filter(([id]) => id !== msg.worktreeId))) +} /** Clear setup indicators for every worktree in one multi-version group. */ export function clearMultiVersionBusy(store: ProjectStore, groupId: string): void { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts index 4809b583d4..4f45a751a5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts @@ -24,7 +24,7 @@ export function createSessionBusy(opts: { projects: () => Record active: () => string | undefined }) { - const any = (ids: string[]) => { + const any = (ids: string[], waiting = false) => { if (ids.length === 0) return false const statuses = opts.statuses() const blocked = new Set( @@ -34,20 +34,29 @@ export function createSessionBusy(opts: { ) return ids.some((id) => { const status = statuses[id] + if (waiting) + return ( + (!!status && status.type !== "idle") || + [...opts.permissions(), ...opts.questions()].some((prompt) => prompt.sessionID === id) + ) return (status?.type === "busy" || status?.type === "retry") && !blocked.has(id) }) } - const agent = (id: string) => + const agent = (id: string, waiting = false) => any( opts .managed() .filter((item) => item.worktreeId === id) .map((item) => item.id), + waiting, ) const local = () => any(opts.local()) - const project = (id: string, worktreeId: string | null) => { - if (id === opts.active()) return worktreeId === null ? local() : agent(worktreeId) - return any((opts.projects()[id] ?? []).filter((item) => item.worktreeId === worktreeId).map((item) => item.id)) + const project = (id: string, worktreeId: string | null, waiting = false) => { + if (id === opts.active()) return worktreeId === null ? any(opts.local(), waiting) : agent(worktreeId, waiting) + return any( + (opts.projects()[id] ?? []).filter((item) => item.worktreeId === worktreeId).map((item) => item.id), + waiting, + ) } return { any, agent, local, project, session: (id: string) => any([id]) } } @@ -69,7 +78,8 @@ export function createWorktreeBusy( active().has(opts.worktrees(project).find((worktree) => worktree.id === id)?.path ?? "") return { ...busy, - agent: (id: string) => busy.agent(id) || working(id), - project: (project: string, id: string | null) => busy.project(project, id) || (id !== null && working(id, project)), + agent: (id: string, waiting = false) => busy.agent(id, waiting) || working(id), + project: (project: string, id: string | null, waiting = false) => + busy.project(project, id, waiting) || (id !== null && working(id, project)), } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 54841acdae..0f290023ca 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -327,6 +327,7 @@ export const SessionProvider: ParentComponent = (props) => { const [busySinceMap, setBusySinceMap] = createStore>({}) const [submissionMap, setSubmissionMap] = createStore>({}) const pendingSubmissions = new Map() + const removedSessions = new Set() const aborts = createAbortState() const idle: SessionStatusInfo = { type: "idle" } @@ -1226,6 +1227,7 @@ export const SessionProvider: ParentComponent = (props) => { // Event handlers function handleSessionCreated(session: SessionInfo, draftID?: string) { + removedSessions.delete(session.id) freshSessions.add(session.id) if (draftID) aborts.move(draftID, session.id) batch(() => { @@ -1654,6 +1656,7 @@ export const SessionProvider: ParentComponent = (props) => { message?: string, next?: number, ) { + if (removedSessions.has(sessionID)) return const shouldAbort = aborts.update(sessionID, newStatus) confirmSubmissions(sessionID) const prev = statusMap[sessionID] ?? { type: "idle" } @@ -1686,6 +1689,7 @@ export const SessionProvider: ParentComponent = (props) => { } function handlePermissionRequest(permission: PermissionRequest) { + if (removedSessions.has(permission.sessionID)) return setPermissions((prev) => upsertPermission(prev, permission)) } @@ -1717,6 +1721,7 @@ export const SessionProvider: ParentComponent = (props) => { } function handleQuestionRequest(question: QuestionRequest) { + if (removedSessions.has(question.sessionID)) return setQuestions((prev) => { const idx = prev.findIndex((q) => q.id === question.id) if (idx === -1) return [...prev, question] @@ -1740,6 +1745,7 @@ export const SessionProvider: ParentComponent = (props) => { } function handleSuggestionRequest(suggestion: SuggestionRequest) { + if (removedSessions.has(suggestion.sessionID)) return setSuggestions((prev) => { const idx = prev.findIndex((item) => item.id === suggestion.id) if (idx === -1) return [...prev, suggestion] @@ -1958,6 +1964,7 @@ export const SessionProvider: ParentComponent = (props) => { } function handleSessionDeleted(sessionID: string) { + removedSessions.add(sessionID) pendingOptimistic.delete(sessionID) freshSessions.delete(sessionID) aborts.clear(sessionID) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 6ead9aa529..c31825b265 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -130,6 +130,8 @@ export interface ErrorMessage { message: string code?: string sessionID?: string + projectId?: string + worktreeId?: string } export interface SendMessageFailedMessage { diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index 27cb5ffa34..baceb015d4 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -58,6 +58,10 @@ export const RemoveAgentPayload = Schema.Struct({ scope: Schema.optional(Scope), }) +export const RemoveSnapshotPayload = Schema.Struct({ + worktree: Schema.String, +}) + export const NotebookReplyPayload = Schema.Struct({ result: NotebookResult }) export const NotebookRejectPayload = Schema.Struct({ error: NotebookFailure }) export const AgentManagerReplyPayload = Schema.Struct({ result: AgentManagerResult }) @@ -69,6 +73,7 @@ export const KilocodePaths = { removeCommand: `${root}/command/remove`, removeSkill: `${root}/skill/remove`, removeAgent: `${root}/agent/remove`, + removeSnapshot: `${root}/snapshot/remove`, providerUsage: `${root}/provider-usage`, providerUsageRefresh: `${root}/provider-usage/refresh`, notebookList: `${root}/notebook`, @@ -145,6 +150,18 @@ export const KilocodeApi = HttpApi.make("kilocode") "Remove a custom (non-native) agent from one writable configuration scope, or every writable scope when omitted, and dispose cached instance state.", }), ), + HttpApiEndpoint.post("removeSnapshot", KilocodePaths.removeSnapshot, { + query: WorkspaceRoutingQuery, + payload: RemoveSnapshotPayload, + success: described(Schema.Boolean, "Snapshot repository removed"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.removeSnapshot", + summary: "Remove a snapshot repository", + description: "Remove the snapshot repository for an already deleted Agent Manager worktree.", + }), + ), HttpApiEndpoint.get("providerUsage", KilocodePaths.providerUsage, { query: WorkspaceRoutingQuery, success: described(ProviderUsage.Info, "Current provider usage"), diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 1963357320..1221a83a86 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -26,6 +26,11 @@ import { BackgroundJob } from "@/background/job" import { SessionRunState } from "@/session/run-state" import { SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { KiloSnapshotCleanup } from "@/kilocode/snapshot/cleanup" +import { Global } from "@opencode-ai/core/global" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import path from "path" import { AgentManagerRejectPayload, AgentManagerReplyPayload, @@ -34,6 +39,7 @@ import { RemoveAgentPayload, RemoveCommandPayload, RemoveSkillPayload, + RemoveSnapshotPayload, BackgroundJobInfo, BackgroundJobsQuery, } from "../groups/kilocode" @@ -51,6 +57,8 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const runState = yield* SessionRunState.Service const flags = yield* RuntimeFlags.Service const locations = yield* LocationServiceMap.Service + const fs = yield* FSUtil.Service + const flock = yield* EffectFlock.Service // Location-scoped services, keyed by the request's directory and workspace. const located = Effect.fnUntraced(function* (effect: Effect.Effect) { @@ -139,6 +147,20 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" return true }) + const removeSnapshot = Effect.fn("KilocodeHttpApi.removeSnapshot")(function* (ctx: { + payload: typeof RemoveSnapshotPayload.Type + }) { + const instance = yield* InstanceState.context + return yield* KiloSnapshotCleanup.remove({ + root: path.join(Global.Path.data, "snapshot"), + project: instance.project.id, + directory: instance.worktree, + worktree: ctx.payload.worktree, + fs, + flock, + }).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + }) + const providerUsage = Effect.fn("KilocodeHttpApi.providerUsage")(function* () { return yield* located(ProviderUsage.Service.use((usage) => usage.get())).pipe( Effect.mapError(() => new HttpApiError.ServiceUnavailable({})), @@ -252,6 +274,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" .handle("removeCommand", removeCommand) .handle("removeSkill", removeSkill) .handle("removeAgent", removeAgent) + .handle("removeSnapshot", removeSnapshot) .handle("providerUsage", providerUsage) .handle("providerUsageRefresh", providerUsageRefresh) .handle("notebookList", notebookList) diff --git a/packages/opencode/src/kilocode/snapshot/cleanup.ts b/packages/opencode/src/kilocode/snapshot/cleanup.ts new file mode 100644 index 0000000000..762352610a --- /dev/null +++ b/packages/opencode/src/kilocode/snapshot/cleanup.ts @@ -0,0 +1,234 @@ +import { FSUtil } from "@opencode-ai/core/fs-util" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Hash } from "@opencode-ai/core/util/hash" +import { Effect } from "effect" +import path from "path" + +export namespace KiloSnapshotCleanup { + export interface Input { + readonly root: string + readonly project: string + readonly directory: string + readonly worktree: string + readonly fs: FSUtil.Interface + readonly flock: EffectFlock.Interface + } + + type Checked = { + readonly canonical: string + readonly exists: boolean + readonly type?: FSUtil.DirEntry["type"] + } + + const normalized = (value: string) => { + const result = path.normalize(value) + return process.platform === "win32" ? result.toLowerCase() : result + } + + const inside = (parent: string, child: string) => FSUtil.contains(normalized(parent), normalized(child)) + + const component = (value: string) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) + + const alias = (value: string, canonical: string) => + process.platform === "darwin" && + ((value === "/var" && canonical === "/private/var") || (value === "/tmp" && canonical === "/private/tmp")) + + const inspect = Effect.fnUntraced(function* (fs: FSUtil.Interface, target: string) { + const root = path.parse(target).root + const parts = path.relative(root, target).split(path.sep).filter(Boolean) + let current = root + let canonical = yield* fs + .realPath(root) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(root))) + + for (const [index, name] of parts.entries()) { + const info = yield* fs + .stat(current) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) + if (!info) { + return { + canonical: path.join(canonical, ...parts.slice(index)), + exists: false, + } satisfies Checked + } + if (info.type !== "Directory") return yield* Effect.fail(new Error("trusted path parent is not a directory")) + + const entries = yield* fs.readDirectoryEntries(current) + const entry = entries.find((item) => item.name === name) + if (!entry) { + canonical = yield* fs.realPath(current) + return { + canonical: path.join(canonical, ...parts.slice(index)), + exists: false, + } satisfies Checked + } + + const next = path.join(current, name) + const real = yield* fs.realPath(next) + const again = (yield* fs.readDirectoryEntries(current)).find((item) => item.name === name) + if (!again || (again.type === "symlink" && !alias(next, real)) || again.type !== entry.type) + return yield* Effect.fail(new Error("trusted path contains an unexpected symlink")) + + current = next + canonical = real + if (index === parts.length - 1) { + return { + canonical, + exists: true, + type: again.type === "symlink" && alias(next, real) ? "directory" : again.type, + } satisfies Checked + } + } + + return { canonical, exists: true, type: "directory" } satisfies Checked + }) + + const dir = (value: Checked, name: string) => { + if (value.exists && value.type !== "directory") return Effect.fail(new Error(`${name} must be a directory`)) + return Effect.void + } + + const absent = (input: Input, worktree: string) => + inspect(input.fs, worktree).pipe(Effect.map((value) => !value.exists)) + + const validate = Effect.fnUntraced(function* ( + input: Input, + paths: { + readonly root: string + readonly project: string + readonly directory: string + readonly managed: string + readonly worktree: string + readonly gitdir: string + }, + ) { + const root = yield* inspect(input.fs, paths.root) + const project = yield* inspect(input.fs, paths.project) + const projectDir = yield* inspect(input.fs, paths.directory) + const managed = yield* inspect(input.fs, paths.managed) + const worktree = yield* inspect(input.fs, paths.worktree) + const gitdir = yield* inspect(input.fs, paths.gitdir) + + yield* dir(root, "snapshot root") + yield* dir(project, "snapshot project") + yield* dir(projectDir, "project directory") + yield* dir(managed, "managed worktrees directory") + if (worktree.exists && worktree.type !== "directory") + return yield* Effect.fail(new Error("worktree must be a directory or absent")) + yield* dir(gitdir, "snapshot repository") + + if (!inside(root.canonical, project.canonical) || normalized(root.canonical) === normalized(project.canonical)) + return yield* Effect.fail(new Error("snapshot project is outside the snapshot root")) + if (!inside(project.canonical, gitdir.canonical) || normalized(project.canonical) === normalized(gitdir.canonical)) + return yield* Effect.fail(new Error("snapshot repository is outside the snapshot project")) + if ( + !inside(projectDir.canonical, managed.canonical) || + normalized(projectDir.canonical) === normalized(managed.canonical) + ) + return yield* Effect.fail(new Error("managed worktrees directory is outside the project directory")) + if ( + !inside(managed.canonical, worktree.canonical) || + normalized(managed.canonical) === normalized(worktree.canonical) + ) + return yield* Effect.fail(new Error("worktree is outside the managed worktrees directory")) + + return { root, project, managed, worktree, gitdir } + }) + + const pending = Effect.fnUntraced(function* (fs: FSUtil.Interface, gitdir: string) { + const root = yield* fs.readDirectoryEntries(gitdir) + const names = new Set(root.map((entry) => entry.name)) + if (names.has("seed.index") || names.has("seed.index.lock") || names.has("seed-objects")) return true + + const objects = root.find((entry) => entry.name === "objects") + if (!objects) return false + if (objects.type !== "directory") return yield* Effect.fail(new Error("snapshot repository objects path is unsafe")) + + const objectEntries = yield* fs.readDirectoryEntries(path.join(gitdir, "objects")) + const info = objectEntries.find((entry) => entry.name === "info") + if (!info) return false + if (info.type !== "directory") + return yield* Effect.fail(new Error("snapshot repository objects info path is unsafe")) + + const markers = yield* fs.readDirectoryEntries(path.join(gitdir, "objects", "info")) + return markers.some( + (entry) => + entry.name === "alternates" || entry.name === "alternates.seed" || entry.name === "alternates.materializing", + ) + }) + + export const remove = Effect.fnUntraced(function* (input: Input) { + const root = path.resolve(input.root) + const directory = path.resolve(input.directory) + const worktree = path.resolve(input.worktree) + const managed = path.resolve(directory, ".kilo", "worktrees") + if (!component(input.project)) return yield* Effect.fail(new Error("project must be a safe path component")) + if (!path.isAbsolute(input.worktree) || worktree === managed || !FSUtil.contains(managed, worktree)) + return yield* Effect.fail(new Error("worktree must be an absolute path inside the managed worktrees directory")) + const child = path.relative(managed, worktree).split(path.sep).filter(Boolean) + if (child.length !== 1 || !component(child[0])) + return yield* Effect.fail(new Error("worktree must be a single safe path component")) + const gitdir = path.join(root, input.project, Hash.fast(worktree)) + if (!inside(root, gitdir) || normalized(gitdir) === normalized(root)) + return yield* Effect.fail(new Error("snapshot repository is outside the snapshot root")) + + return yield* input.flock.withLock( + Effect.gen(function* () { + const paths = { root, project: path.join(root, input.project), directory, managed, worktree, gitdir } + const checked = yield* validate(input, paths) + if (!(yield* absent(input, worktree))) + return yield* Effect.fail(new Error("worktree must be absent before its snapshot repository is removed")) + if (checked.project.exists) { + const prefix = `.${path.basename(gitdir)}.cleanup-` + const entries = yield* input.fs.readDirectoryEntries(checked.project.canonical) + for (const entry of entries) { + if (!entry.name.startsWith(prefix)) continue + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(entry.name.slice(prefix.length)) + ) + continue + const target = path.join(checked.project.canonical, entry.name) + const retained = yield* inspect(input.fs, target) + yield* dir(retained, "snapshot cleanup quarantine") + if (!retained.exists) continue + if (normalized(retained.canonical) !== normalized(target) || (yield* pending(input.fs, target))) + return yield* Effect.fail(new Error("snapshot cleanup quarantine is unsafe or still pending")) + if (!(yield* absent(input, worktree))) + return yield* Effect.fail(new Error("worktree must be absent before its snapshot repository is removed")) + yield* Effect.uninterruptible(input.fs.remove(target, { recursive: true, force: true })) + } + } + if (!checked.gitdir.exists) return true + + const final = yield* validate(input, paths) + if (!(yield* absent(input, worktree))) + return yield* Effect.fail(new Error("worktree must be absent before its snapshot repository is removed")) + if (!final.gitdir.exists) return true + if (yield* pending(input.fs, final.gitdir.canonical)) + return yield* Effect.fail(new Error("snapshot repository materialization is still pending")) + + const quarantine = path.join( + path.dirname(final.gitdir.canonical), + `.${path.basename(final.gitdir.canonical)}.cleanup-${crypto.randomUUID()}`, + ) + const available = yield* inspect(input.fs, quarantine) + if (available.exists) return yield* Effect.fail(new Error("snapshot cleanup quarantine already exists")) + const moved = yield* input.fs.rename(final.gitdir.canonical, quarantine).pipe( + Effect.as(true), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + ) + if (!moved) return true + const movedPath = yield* inspect(input.fs, quarantine) + if ( + !movedPath.exists || + normalized(movedPath.canonical) !== normalized(quarantine) || + (yield* pending(input.fs, quarantine)) + ) + return yield* Effect.fail(new Error("snapshot repository changed during cleanup")) + yield* Effect.uninterruptible(input.fs.remove(quarantine, { recursive: true, force: true })) + return true + }), + `snapshot:${gitdir}`, + ) + }) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index fc1e3c2fb2..e9358b004e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -13,7 +13,7 @@ const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' // kilocode_change start - require auth for high-risk permission toggles even when global auth is optional -const REQUIRED_AUTH_PATHS = new Set(["/permission/allow-everything"]) +const REQUIRED_AUTH_PATHS = new Set(["/permission/allow-everything", "/kilocode/snapshot/remove"]) // kilocode_change end // Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index a7f873cfc7..970165cce5 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -602,6 +602,24 @@ export const kiloScenarios: Scenario[] = [ yield* Effect.promise(() => rm(body, { force: true })) }), ), + http.protected + .post("/kilocode/snapshot/remove", "kilocode.removeSnapshot") + .mutating() + .inProject({ git: true }) + .seeded((ctx) => + Effect.gen(function* () { + const worktree = path.join(directory(ctx), ".kilo", "worktrees", "api-snapshot-remove") + yield* Effect.promise(() => mkdir(worktree, { recursive: true })) + yield* Effect.promise(() => rm(worktree, { recursive: true, force: true })) + return worktree + }), + ) + .at((ctx) => ({ + path: `/kilocode/snapshot/remove?directory=${encodeURIComponent(directory(ctx))}`, + headers: ctx.headers(), + body: { worktree: ctx.state }, + })) + .status(401), http.protected .get("/kilocode/command/files", "kilocode.commandFiles") .inProject({ git: true, init: command }) diff --git a/packages/opencode/test/kilocode/server/httpapi-snapshot-auth.test.ts b/packages/opencode/test/kilocode/server/httpapi-snapshot-auth.test.ts new file mode 100644 index 0000000000..a21bdc02de --- /dev/null +++ b/packages/opencode/test/kilocode/server/httpapi-snapshot-auth.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ConfigProvider, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import path from "path" +import { ServerAuth } from "../../../src/server/auth" +import { HttpApiApp } from "../../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../../fixture/db" +import { disposeAllInstances, tmpdir } from "../../fixture/fixture" + +const original = { + password: Flag.KILO_SERVER_PASSWORD, + username: Flag.KILO_SERVER_USERNAME, + envPassword: process.env.KILO_SERVER_PASSWORD, + envUsername: process.env.KILO_SERVER_USERNAME, +} + +afterEach(async () => { + Flag.KILO_SERVER_PASSWORD = original.password + Flag.KILO_SERVER_USERNAME = original.username + if (original.envPassword === undefined) delete process.env.KILO_SERVER_PASSWORD + else process.env.KILO_SERVER_PASSWORD = original.envPassword + if (original.envUsername === undefined) delete process.env.KILO_SERVER_USERNAME + else process.env.KILO_SERVER_USERNAME = original.envUsername + await disposeAllInstances() + await resetDatabase() +}) + +function app(input: { password?: string; username?: string }) { + const handler = HttpRouter.toWebHandler( + HttpApiApp.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_SERVER_PASSWORD: input.password, + KILO_SERVER_USERNAME: input.username, + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER ?? "true", + }), + ), + ), + ), + { disableLogger: true }, + ).handler + + return { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + HttpApiApp.context, + ) + }, + } +} + +function basic(username: string, password: string) { + return ServerAuth.header({ username, password }) ?? "" +} + +function setAuth(password: string) { + Flag.KILO_SERVER_PASSWORD = password + Flag.KILO_SERVER_USERNAME = undefined + process.env.KILO_SERVER_PASSWORD = password + delete process.env.KILO_SERVER_USERNAME +} + +describe("POST /kilocode/snapshot/remove authorization", () => { + test("fails closed without configured auth and requires valid credentials when configured", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const worktree = path.join(tmp.path, ".kilo", "worktrees", "snapshot-auth") + const route = `/kilocode/snapshot/remove?directory=${encodeURIComponent(tmp.path)}` + const init = (authorization?: string): RequestInit => ({ + method: "POST", + headers: { + "content-type": "application/json", + "x-kilo-directory": tmp.path, + ...(authorization ? { authorization } : {}), + }, + body: JSON.stringify({ worktree }), + }) + + const noAuth = app({}) + const unsecured = await noAuth.request(route, init()) + expect(unsecured.status).toBe(401) + + setAuth("secret") + const secured = app({ password: "secret" }) + const missing = await secured.request(route, init()) + const invalid = await secured.request(route, init(basic("kilo", "wrong"))) + expect(missing.status).toBe(401) + expect(invalid.status).toBe(401) + + const valid = await secured.request(route, init(basic("kilo", "secret"))) + expect(valid.status).toBe(200) + expect(await valid.json()).toBe(true) + }) +}) diff --git a/packages/opencode/test/kilocode/snapshot-repository-cleanup.test.ts b/packages/opencode/test/kilocode/snapshot-repository-cleanup.test.ts new file mode 100644 index 0000000000..03b2845733 --- /dev/null +++ b/packages/opencode/test/kilocode/snapshot-repository-cleanup.test.ts @@ -0,0 +1,510 @@ +import { expect } from "bun:test" +import * as nativeFs from "fs/promises" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Database } from "@opencode-ai/core/database/database" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppProcess } from "@opencode-ai/core/process" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Hash } from "@opencode-ai/core/util/hash" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionID } from "../../src/session/schema" +import { KiloSnapshotCleanup } from "../../src/kilocode/snapshot/cleanup" +import { tmpdirScoped, testInstanceStoreLayer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import path from "path" +import { eq } from "drizzle-orm" + +const env = Layer.mergeAll( + LayerNode.compile( + LayerNode.group([FSUtil.node, AppProcess.node, EffectFlock.node, Database.node, CrossSpawnSpawner.node]), + ), + testInstanceStoreLayer, +) +const it = testEffect(env) + +const git = (args: string[], opts?: { cwd?: string; env?: Record }) => + Effect.gen(function* () { + const app = yield* AppProcess.Service + const result = yield* app.run(ChildProcess.make("git", args, { cwd: opts?.cwd, env: opts?.env, extendEnv: true }), { + maxOutputBytes: 8192, + maxErrorBytes: 8192, + }) + if (result.exitCode !== 0) { + return yield* Effect.die(new Error(`${result.command}: ${result.stderr.toString("utf8")}`)) + } + return result + }) + +const write = (file: string, value: string | Uint8Array = "") => + FSUtil.Service.use((fs) => fs.writeWithDirs(file, value).pipe(Effect.orDie)) + +const exist = (file: string) => FSUtil.Service.use((fs) => fs.existsSafe(file)) + +const drop = (file: string) => + FSUtil.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.orDie)) + +const link = (target: string, file: string) => Effect.promise(() => nativeFs.symlink(target, file)) + +const tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + +const item = (base: string, project = "project", name = "worktree", directory = path.join(base, "project")) => ({ + root: path.join(base, "snapshots"), + project, + directory, + worktree: path.join(directory, ".kilo", "worktrees", name), +}) + +const repo = (input: ReturnType) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const dir = path.join(input.root, input.project, Hash.fast(input.worktree)) + yield* fs.ensureDir(dir).pipe(Effect.orDie) + yield* fs.ensureDir(input.worktree).pipe(Effect.orDie) + yield* fs.ensureDir(input.directory).pipe(Effect.orDie) + yield* git(["init"], { cwd: input.directory, env: { GIT_DIR: dir, GIT_WORK_TREE: input.worktree } }) + yield* git(["config", "user.email", "test@opencode.test"], { cwd: input.directory, env: { GIT_DIR: dir } }) + yield* git(["config", "user.name", "Test"], { cwd: input.directory, env: { GIT_DIR: dir } }) + const commit = yield* git(["--git-dir", dir, "commit-tree", tree, "-m", "snapshot"], { cwd: input.directory }) + yield* git(["--git-dir", dir, "update-ref", "HEAD", commit.stdout.toString("utf8").trim()], { + cwd: input.directory, + }) + return { ...input, dir } + }) + +const remove = (input: ReturnType) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const flock = yield* EffectFlock.Service + return yield* KiloSnapshotCleanup.remove({ ...input, fs, flock }) + }) + +it.live("removes an explicitly deleted snapshot repository recursively", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "removed") + const current = yield* repo(input) + yield* drop(input.worktree) + const lfs = path.join(current.dir, "lfs", "objects", "aa", "bb", "object") + yield* write(lfs, new Uint8Array([1, 2, 3])) + yield* write(path.join(current.dir, "objects", "pack", "pack-test.pack"), "pack") + + expect(yield* remove(input)).toBe(true) + expect(yield* exist(current.dir)).toBe(false) + expect(yield* exist(lfs)).toBe(false) + }), +) + +it.live("leaves retained session history untouched", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "retained") + const current = yield* repo(input) + yield* drop(input.worktree) + const project = ProjectV2.ID.make(`proj_cleanup_${crypto.randomUUID()}`) + const archived = SessionID.descending(`ses_cleanup_archived_${crypto.randomUUID()}`) + const active = SessionID.descending(`ses_cleanup_active_${crypto.randomUUID()}`) + const now = Date.now() + const { db } = yield* Database.Service + + yield* db + .insert(ProjectTable) + .values({ + id: project, + worktree: AbsolutePath.make(input.directory), + vcs: "git", + time_created: now, + time_updated: now, + sandboxes: [], + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values([ + { + id: archived, + project_id: project, + slug: "cleanup-archived", + directory: input.worktree, + title: "archived", + version: "test", + time_created: now, + time_updated: now, + time_archived: now, + }, + { + id: active, + project_id: project, + slug: "cleanup-active", + directory: input.worktree, + title: "active", + version: "test", + time_created: now, + time_updated: now, + }, + ]) + .run() + .pipe(Effect.orDie) + + expect(yield* remove(input)).toBe(true) + expect(yield* exist(current.dir)).toBe(false) + const rows = yield* db + .select({ id: SessionTable.id, directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.project_id, project)) + .all() + .pipe(Effect.orDie) + expect(rows).toEqual([ + { id: archived, directory: input.worktree }, + { id: active, directory: input.worktree }, + ]) + }), +) + +it.live("refuses to remove a live worktree", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "live") + const current = yield* repo(input) + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + expect(yield* exist(input.worktree)).toBe(true) + expect(yield* exist(current.dir)).toBe(true) + }), +) + +it.live("rejects paths outside the managed worktrees directory", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "outside") + const outside = path.join(input.directory, ".kilo", "worktrees-evil", "outside") + yield* write(path.join(outside, "sentinel"), "keep") + + expect(Exit.isFailure(yield* remove({ ...input, worktree: outside }).pipe(Effect.exit))).toBe(true) + expect(yield* exist(path.join(outside, "sentinel"))).toBe(true) + }), +) + +it.live("isolates sibling snapshot repositories", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const first = item(base, "project", "first") + const second = item(base, "project", "second") + const one = yield* repo(first) + const two = yield* repo(second) + yield* drop(first.worktree) + yield* drop(second.worktree) + + expect(yield* remove(first)).toBe(true) + expect(yield* exist(one.dir)).toBe(false) + expect(yield* exist(two.dir)).toBe(true) + }), +) + +it.live("isolates snapshot repositories by project", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const first = item(base, "project-one", "shared") + const second = item(base, "project-two", "shared") + const one = yield* repo(first) + const two = yield* repo(second) + yield* drop(first.worktree) + + expect(yield* remove(first)).toBe(true) + expect(yield* exist(one.dir)).toBe(false) + expect(yield* exist(two.dir)).toBe(true) + }), +) + +it.live("finishes an interrupted quarantine without deleting unrelated directories", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "retry") + const current = yield* repo(input) + yield* drop(input.worktree) + const quarantine = path.join( + path.dirname(current.dir), + `.${path.basename(current.dir)}.cleanup-${crypto.randomUUID()}`, + ) + const unrelated = path.join(path.dirname(current.dir), ".other.cleanup-00000000-0000-0000-0000-000000000000") + const fs = yield* FSUtil.Service + yield* fs.rename(current.dir, quarantine) + yield* write(path.join(unrelated, "keep"), "keep") + + expect(yield* remove(input)).toBe(true) + expect(yield* exist(quarantine)).toBe(false) + expect(yield* exist(unrelated)).toBe(true) + expect(yield* remove(input)).toBe(true) + }), +) + +it.live("preserves a pending quarantine and completes cleanup after materialization", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "retry-pending") + const current = yield* repo(input) + yield* drop(input.worktree) + const quarantine = path.join( + path.dirname(current.dir), + `.${path.basename(current.dir)}.cleanup-${crypto.randomUUID()}`, + ) + const fs = yield* FSUtil.Service + yield* fs.rename(current.dir, quarantine) + const marker = path.join(quarantine, "seed.index") + yield* write(marker, "pending") + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + expect(yield* exist(quarantine)).toBe(true) + yield* drop(marker) + expect(yield* remove(input)).toBe(true) + expect(yield* exist(quarantine)).toBe(false) + }), +) + +it.live("rejects a symlinked cleanup quarantine during a retry", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "retry-symlink") + const current = yield* repo(input) + yield* drop(input.worktree) + yield* drop(current.dir) + const outside = path.join(base, "outside-quarantine") + yield* write(path.join(outside, "keep"), "keep") + const quarantine = path.join( + path.dirname(current.dir), + `.${path.basename(current.dir)}.cleanup-${crypto.randomUUID()}`, + ) + yield* link(outside, quarantine) + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + expect(yield* exist(path.join(outside, "keep"))).toBe(true) + }), +) + +it.live("removes an absent snapshot repository idempotently", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "absent") + + expect(yield* remove(input)).toBe(true) + expect(yield* remove(input)).toBe(true) + }), +) + +it.live("removes safely when the snapshot root, project, or repository is missing", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "missing-levels", "missing-levels") + const fs = yield* FSUtil.Service + + expect(yield* remove(input)).toBe(true) + yield* fs.ensureDir(input.root).pipe(Effect.orDie) + expect(yield* remove(input)).toBe(true) + yield* fs.ensureDir(path.join(input.root, input.project)).pipe(Effect.orDie) + expect(yield* remove(input)).toBe(true) + }), +) + +it.live("waits for the snapshot repository lock", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "locked") + const current = yield* repo(input) + yield* drop(input.worktree) + const flock = yield* EffectFlock.Service + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const held = yield* flock + .withLock( + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + }), + `snapshot:${current.dir}`, + ) + .pipe(Effect.forkChild) + yield* Deferred.await(entered) + const removing = yield* remove(input).pipe(Effect.forkChild) + yield* Effect.yieldNow + expect(yield* exist(current.dir)).toBe(true) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(held) + yield* Fiber.join(removing) + expect(yield* exist(current.dir)).toBe(false) + }), +) + +it.live("rechecks worktree absence after waiting for the lock", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "locked-recheck") + const current = yield* repo(input) + yield* drop(input.worktree) + const fs = yield* FSUtil.Service + const flock = yield* EffectFlock.Service + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const held = yield* flock + .withLock( + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + }), + `snapshot:${current.dir}`, + ) + .pipe(Effect.forkChild) + yield* Deferred.await(entered) + const removing = yield* remove(input).pipe(Effect.forkChild) + yield* Effect.yieldNow + yield* fs.ensureDir(input.worktree).pipe(Effect.orDie) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(held) + expect(Exit.isFailure(yield* Fiber.await(removing))).toBe(true) + expect(yield* exist(current.dir)).toBe(true) + yield* drop(input.worktree) + }), +) + +it.live("rejects a symlinked snapshot root", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const outside = path.join(base, "outside-root") + const input = item(base, "root-link", "root-link") + yield* write(path.join(outside, "sentinel"), "keep") + yield* link(outside, input.root) + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + expect(yield* exist(path.join(outside, "sentinel"))).toBe(true) + }), +) + +it.live("rejects a symlinked snapshot project", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const outside = path.join(base, "outside-project") + const input = item(base, "project-link", "project-link") + yield* write(path.join(outside, "sentinel"), "keep") + yield* write(path.join(input.root, "placeholder"), "") + yield* link(outside, path.join(input.root, input.project)) + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + expect(yield* exist(path.join(outside, "sentinel"))).toBe(true) + }), +) + +it.live("rejects a symlinked snapshot repository", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const outside = path.join(base, "outside-repository") + const input = item(base, "repository-link", "repository-link") + const current = yield* repo(input) + yield* drop(input.worktree) + yield* write(path.join(outside, "sentinel"), "keep") + yield* drop(current.dir) + yield* link(outside, current.dir) + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + expect(yield* exist(path.join(outside, "sentinel"))).toBe(true) + }), +) + +it.live("rejects a dangling managed worktree symlink", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "dangling-worktree", "dangling-worktree") + yield* repo(input) + yield* drop(input.worktree) + yield* write(path.join(base, "outside"), "keep") + yield* link(path.join(base, "missing"), input.worktree) + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + const entries = yield* FSUtil.Service.use((fs) => fs.readDirectoryEntries(path.dirname(input.worktree))) + expect(entries.find((entry) => entry.name === path.basename(input.worktree))?.type).toBe("symlink") + expect(yield* exist(path.join(base, "outside"))).toBe(true) + }), +) + +for (const name of [".kilo", "worktrees"]) { + it.live(`rejects a symlinked managed ${name} directory`, () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, `managed-${name}`, `managed-${name}`) + const outside = path.join(base, `outside-${name}`) + yield* write(path.join(outside, "sentinel"), "keep") + if (name === ".kilo") { + yield* write(path.join(input.directory, "placeholder"), "") + yield* link(outside, path.join(input.directory, name)) + } else { + yield* write(path.join(input.directory, ".kilo", "placeholder"), "") + yield* link(outside, path.join(input.directory, ".kilo", name)) + } + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + expect(yield* exist(path.join(outside, "sentinel"))).toBe(true) + }), + ) +} + +for (const marker of [ + "objects/info/alternates", + "objects/info/alternates.seed", + "objects/info/alternates.materializing", + "seed-objects/part", + "seed.index", + "seed.index.lock", +]) { + it.live(`does not remove a repository with ${marker} pending`, () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "pending", marker.replaceAll("/", "-")) + const current = yield* repo(input) + yield* drop(input.worktree) + yield* write(path.join(current.dir, marker), "pending") + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + expect(yield* exist(current.dir)).toBe(true) + }), + ) +} + +it.live("accepts a macOS temporary-directory alias", () => + Effect.gen(function* () { + if (process.platform !== "darwin") return + const base = yield* tmpdirScoped() + const aliasBase = base.replace(/^\/private/, "") + const input = item(aliasBase, "macos-alias", "macos-alias") + const current = yield* repo(input) + yield* drop(input.worktree) + + expect(yield* remove(input)).toBe(true) + expect(yield* exist(current.dir)).toBe(false) + }), +) + +for (const project of ["", ".", "..", "project/name", "/tmp/project", "project\\name"]) { + it.live(`rejects malformed project component ${JSON.stringify(project)}`, () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, project, "malformed-project") + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + }), + ) +} + +for (const name of ["", ".", "..", "worktree/name", "worktree\\name"]) { + it.live(`rejects malformed worktree component ${JSON.stringify(name)}`, () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "valid-project", name) + + expect(Exit.isFailure(yield* remove(input).pipe(Effect.exit))).toBe(true) + }), + ) +} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 98aa543392..d0d36d40bb 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -209,6 +209,8 @@ import type { KilocodeRemoveCommandResponses, KilocodeRemoveSkillErrors, KilocodeRemoveSkillResponses, + KilocodeRemoveSnapshotErrors, + KilocodeRemoveSnapshotResponses, KilocodeSessionImportMessageErrors, KilocodeSessionImportMessageResponses, KilocodeSessionImportPartErrors, @@ -8473,6 +8475,47 @@ export class Kilocode extends HeyApiClient { ) } + /** + * Remove a snapshot repository + * + * Remove the snapshot repository for an already deleted Agent Manager worktree. + */ + public removeSnapshot( + parameters?: { + directory?: string + workspace?: string + worktree?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "worktree" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeRemoveSnapshotResponses, + KilocodeRemoveSnapshotErrors, + ThrowOnError + >({ + url: "/kilocode/snapshot/remove", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Get session model usage * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f0a7d0d670..a6c123fc18 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -16737,6 +16737,36 @@ export type KilocodeRemoveAgentResponses = { export type KilocodeRemoveAgentResponse = KilocodeRemoveAgentResponses[keyof KilocodeRemoveAgentResponses] +export type KilocodeRemoveSnapshotData = { + body?: { + worktree: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/snapshot/remove" +} + +export type KilocodeRemoveSnapshotErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type KilocodeRemoveSnapshotError = KilocodeRemoveSnapshotErrors[keyof KilocodeRemoveSnapshotErrors] + +export type KilocodeRemoveSnapshotResponses = { + /** + * Snapshot repository removed + */ + 200: boolean +} + +export type KilocodeRemoveSnapshotResponse = KilocodeRemoveSnapshotResponses[keyof KilocodeRemoveSnapshotResponses] + export type KilocodeProviderUsageGetData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 578db604f6..99c75e8699 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -15440,6 +15440,84 @@ ] } }, + "/kilocode/snapshot/remove": { + "post": { + "tags": ["kilocode"], + "operationId": "kilocode.removeSnapshot", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Snapshot repository removed", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Snapshot repository removed" + } + } + } + }, + "400": { + "description": "BadRequest | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + } + }, + "description": "Remove the snapshot repository for an already deleted Agent Manager worktree.", + "summary": "Remove a snapshot repository", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "worktree": { + "type": "string" + } + }, + "required": ["worktree"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.removeSnapshot({\n ...\n})" + } + ] + } + }, "/kilocode/provider-usage": { "get": { "tags": ["kilocode"],