mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
fix(agent-manager): safely clean up deleted worktree snapshots
This commit is contained in:
@@ -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.
|
||||
@@ -379,6 +379,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private promptRecoveryQueued = false
|
||||
private promptRecovery: Promise<void> | null = null
|
||||
private trackedSessionIds: Set<string> = new Set()
|
||||
private readonly removedSessionIds = new Set<string>()
|
||||
private readonly openSessionIds = new Set<string>()
|
||||
private modelUsageSessionIds: Set<string> = new Set()
|
||||
private syncedChildSessions: Set<string> = 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)
|
||||
@@ -5434,6 +5438,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()
|
||||
|
||||
@@ -118,6 +118,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
private onVisibilityChange: ((visible: boolean) => void) | undefined
|
||||
private panelSessions = new Set<string>()
|
||||
private busySessions = new Set<string>()
|
||||
private removedSessions = new Set<string>()
|
||||
readonly settings: ProjectWiring["settings"]
|
||||
/** Session ID most recently loaded via `loadMessages`; updated synchronously. */
|
||||
private activeSessionId: string | undefined
|
||||
@@ -300,7 +301,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
(event) => this.onSessionLifecycle(event),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep each project's cached sidebar session list in sync with backend
|
||||
* session lifecycle events, so sessions created outside this panel (another
|
||||
@@ -315,6 +315,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
|
||||
@@ -323,6 +324,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
return
|
||||
}
|
||||
const info = ev.properties?.info
|
||||
if (ev.type === "session.created" && info) this.removedSessions.delete(info.id)
|
||||
const dir = info?.directory
|
||||
// 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.
|
||||
@@ -339,12 +341,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)
|
||||
@@ -353,12 +354,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")
|
||||
@@ -1129,7 +1128,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
req,
|
||||
)
|
||||
}
|
||||
|
||||
// Worktree actions
|
||||
|
||||
/** Create a new worktree with an auto-created first session. */
|
||||
@@ -1468,6 +1466,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),
|
||||
|
||||
@@ -9,6 +9,8 @@ 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"
|
||||
const DELETE_ERROR = "agentManager.worktreeDeleteFailed"
|
||||
|
||||
/**
|
||||
* Provider capabilities the worktree lifecycle needs beyond project state.
|
||||
@@ -25,6 +27,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<string, string> | undefined
|
||||
abort: (sessionIds: string[]) => Promise<void>
|
||||
forget: (sessionId: string) => void
|
||||
@@ -114,13 +122,68 @@ export async function deleteLifecycleWorktree(
|
||||
host.log(`Worktree ${worktreeId} not found in state`)
|
||||
return null
|
||||
}
|
||||
let client: KiloClient
|
||||
try {
|
||||
client = host.client()
|
||||
const [status, permissions, questions] = 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 }),
|
||||
])
|
||||
if (status.data === undefined || permissions.data === undefined || questions.data === undefined)
|
||||
throw new Error("Deletion safety checks returned no data")
|
||||
const active = Object.values(status.data).some((value) => value.type !== "idle")
|
||||
if (active || permissions.data.length > 0 || questions.data.length > 0) {
|
||||
host.post({
|
||||
type: "error",
|
||||
code: DELETE_ERROR,
|
||||
projectId: ctx.id,
|
||||
worktreeId,
|
||||
message: "Cannot delete a worktree while a session is active or waiting for input",
|
||||
})
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
host.log(`Failed to verify worktree deletion safety: ${error}`)
|
||||
host.post({
|
||||
type: "error",
|
||||
code: DELETE_ERROR,
|
||||
projectId: ctx.id,
|
||||
worktreeId,
|
||||
message: "Cannot verify worktree sessions before deletion",
|
||||
})
|
||||
return null
|
||||
}
|
||||
// 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" })
|
||||
host.log(`Failed to stop worktree services: ${error}`)
|
||||
host.post({
|
||||
type: "error",
|
||||
code: DELETE_ERROR,
|
||||
projectId: ctx.id,
|
||||
worktreeId,
|
||||
message: "Failed to stop worktree services before deletion",
|
||||
})
|
||||
return null
|
||||
}
|
||||
const cleared = await host.clearRun(worktreeId).catch((error) => {
|
||||
host.log(`Failed to stop the Run script: ${error}`)
|
||||
return false
|
||||
})
|
||||
if (!cleared) {
|
||||
host.unskipStats(worktreeId)
|
||||
host.post({
|
||||
type: "error",
|
||||
code: DELETE_ERROR,
|
||||
projectId: ctx.id,
|
||||
worktreeId,
|
||||
message: "Failed to stop the Run script before deleting the worktree",
|
||||
})
|
||||
return null
|
||||
}
|
||||
const branch = worktree.branchOwned === false ? undefined : (worktree.originalBranch ?? worktree.branch)
|
||||
@@ -130,17 +193,40 @@ export async function deleteLifecycleWorktree(
|
||||
} catch (error) {
|
||||
host.log(`Failed to remove worktree from disk: ${error}`)
|
||||
host.unskipStats(worktreeId)
|
||||
host.post({
|
||||
type: "error",
|
||||
code: DELETE_ERROR,
|
||||
projectId: ctx.id,
|
||||
worktreeId,
|
||||
message: "Failed to remove worktree PTYs before deletion",
|
||||
})
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await ctx.worktreeManager().removeWorktree(worktree.path, branch)
|
||||
try {
|
||||
await client.kilocode.removeSnapshot({ directory: ctx.root, worktree: worktree.path }, { throwOnError: true })
|
||||
} catch (error) {
|
||||
host.log(`Failed to remove worktree snapshots: ${error}`)
|
||||
}
|
||||
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 s of orphaned) routeProjectSession(host.sessions, ctx.id, s.id, 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}`)
|
||||
host.post({
|
||||
type: "error",
|
||||
code: DELETE_ERROR,
|
||||
projectId: ctx.id,
|
||||
worktreeId,
|
||||
message: "Failed to delete the worktree",
|
||||
})
|
||||
return null
|
||||
} finally {
|
||||
releasePtyCleanup()
|
||||
}
|
||||
|
||||
@@ -258,6 +258,9 @@ interface ScriptTerminalsMessage {
|
||||
interface ErrorOutMessage {
|
||||
type: "error"
|
||||
message: string
|
||||
code?: string
|
||||
projectId?: string
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
interface SessionAddedMessage {
|
||||
|
||||
@@ -637,12 +637,37 @@ 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(status).toContain("this.removedSessions.has(sid)")
|
||||
})
|
||||
|
||||
it("onDeleteWorktree removes snapshots after disk cleanup before state cleanup", () => {
|
||||
const text = body("onDeleteWorktree")
|
||||
expect(text).toContain("worktreeManager().removeWorktree")
|
||||
expect(text).toContain("state.removeWorktree")
|
||||
expect(text).toContain("sessions.clearDirectory")
|
||||
const check = text.indexOf("client.session.status")
|
||||
const disk = text.indexOf("await ctx.worktreeManager().removeWorktree(worktree.path, branch)")
|
||||
const snapshot = text.indexOf(".kilocode.removeSnapshot")
|
||||
const state = text.indexOf("state.removeWorktree")
|
||||
|
||||
expect(check).toBeGreaterThanOrEqual(0)
|
||||
expect(check).toBeLessThan(disk)
|
||||
expect(disk).toBeGreaterThanOrEqual(0)
|
||||
expect(snapshot).toBeGreaterThan(disk)
|
||||
expect(state).toBeGreaterThan(snapshot)
|
||||
expect(text).toContain("directory: ctx.root")
|
||||
expect(text).toContain("worktree: worktree.path")
|
||||
expect(text).toContain("throwOnError: true")
|
||||
expect(text).not.toContain("session.delete")
|
||||
expect(text).toContain("routeProjectSession(host.sessions, ctx.id, s.id, ctx.root, ctx.generation)")
|
||||
expect(text).not.toContain("sessions.clearDirectory(s.id)")
|
||||
expect(text).toContain("host.push()")
|
||||
for (const name of ["onCreateWorktree", "onCreateMultiVersion", "onRemoveStaleWorktree"]) {
|
||||
expect(body(name)).not.toContain("removeSnapshot")
|
||||
}
|
||||
})
|
||||
|
||||
// -- onCreateWorktree invariants -------------------------------------------
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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<typeof mock>; delete: ReturnType<typeof mock> }
|
||||
permission: { list: ReturnType<typeof mock> }
|
||||
question: { list: ReturnType<typeof mock> }
|
||||
kilocode: { removeSnapshot: ReturnType<typeof mock> }
|
||||
}
|
||||
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<string, SessionStatus> })),
|
||||
delete: mock(async () => ({ data: true })),
|
||||
},
|
||||
permission: { list: mock(async () => ({ data: [] })) },
|
||||
question: { list: mock(async () => ({ data: [] })) },
|
||||
kilocode: { removeSnapshot: mock(async () => ({ 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}`),
|
||||
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("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.kilocode.removeSnapshot).toHaveBeenCalledWith(
|
||||
{ directory: ctx.root, worktree },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
expect(state.getWorktrees()).toHaveLength(0)
|
||||
expect(state.getSessions()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -226,6 +226,8 @@ type ProviderInternals = {
|
||||
sessionDirectories: Map<string, string>
|
||||
sessionStatusMap: Map<string, string>
|
||||
trackedSessionIds: Set<string>
|
||||
removedSessionIds: Set<string>
|
||||
sessionStatusMap: Map<string, string>
|
||||
openSessionIds: Set<string>
|
||||
draftSessions: Map<string, { sid: string; dir: string; expires: number }>
|
||||
checkpoints: Map<string, Promise<void>>
|
||||
@@ -1136,6 +1138,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", () => {
|
||||
|
||||
@@ -209,6 +209,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", () => {
|
||||
@@ -222,7 +234,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", () => {
|
||||
|
||||
@@ -1386,6 +1386,10 @@ const AgentManagerContent: Component = () => {
|
||||
})
|
||||
|
||||
const unsub = vscode.onMessage((msg) => {
|
||||
if (msg.type === "error" && msg.code === "agentManager.worktreeDeleteFailed" && msg.worktreeId) {
|
||||
const store = msg.projectId ? registry.ensure(msg.projectId) : registry.active()
|
||||
store.setBusy((prev) => new Map([...prev].filter(([id]) => id !== msg.worktreeId)))
|
||||
}
|
||||
if (msg.type === "agentManager.repoInfo") {
|
||||
const info = msg as AgentManagerRepoInfoMessage
|
||||
setRepoBranch(info.branch)
|
||||
@@ -1820,8 +1824,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) || (run && run !== "idle")) return
|
||||
// Second press/click: execute the delete
|
||||
if (pendingDelete() === worktreeId) {
|
||||
cancelPendingDelete()
|
||||
|
||||
@@ -303,7 +303,7 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
{props.shortcut}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!props.busy && !props.pendingDelete}>
|
||||
<Show when={!props.busy && !props.working && !props.pendingDelete}>
|
||||
<div
|
||||
class="am-worktree-close"
|
||||
onMouseEnter={() => setOverClose(true)}
|
||||
@@ -520,17 +520,19 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
<Icon name="edit" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.worktree.rename")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={() => props.onDelete(new MouseEvent("click"))}>
|
||||
<Icon name="trash" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.worktree.delete")}</ContextMenu.ItemLabel>
|
||||
<Show when={props.closeKeybind}>
|
||||
<span class="am-menu-shortcut">
|
||||
{parseBindingTokens(props.closeKeybind).map((token) => (
|
||||
<kbd class="am-menu-key">{token}</kbd>
|
||||
))}
|
||||
</span>
|
||||
</Show>
|
||||
</ContextMenu.Item>
|
||||
<Show when={!props.busy && !props.working}>
|
||||
<ContextMenu.Item onSelect={() => props.onDelete(new MouseEvent("click"))}>
|
||||
<Icon name="trash" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.worktree.delete")}</ContextMenu.ItemLabel>
|
||||
<Show when={props.closeKeybind}>
|
||||
<span class="am-menu-shortcut">
|
||||
{parseBindingTokens(props.closeKeybind).map((token) => (
|
||||
<kbd class="am-menu-key">{token}</kbd>
|
||||
))}
|
||||
</span>
|
||||
</Show>
|
||||
</ContextMenu.Item>
|
||||
</Show>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onSelect={() => props.onOpen()}>
|
||||
<Icon name="open-file" size="small" />
|
||||
|
||||
@@ -327,6 +327,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
const [busySinceMap, setBusySinceMap] = createStore<Record<string, number>>({})
|
||||
const [submissionMap, setSubmissionMap] = createStore<Record<string, number>>({})
|
||||
const pendingSubmissions = new Map<string, string>()
|
||||
const removedSessions = new Set<string>()
|
||||
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)
|
||||
|
||||
@@ -130,6 +130,8 @@ export interface ErrorMessage {
|
||||
message: string
|
||||
code?: string
|
||||
sessionID?: string
|
||||
projectId?: string
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
export interface SendMessageFailedMessage {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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* <A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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"))
|
||||
|
||||
yield* input.flock.withLock(
|
||||
Effect.gen(function* () {
|
||||
const paths = { root, project: path.join(root, input.project), directory, managed, worktree, gitdir }
|
||||
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"))
|
||||
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.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}`,
|
||||
)
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,445 @@
|
||||
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<string, string> }) =>
|
||||
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<typeof item>) =>
|
||||
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<typeof item>) =>
|
||||
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("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<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
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<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
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")
|
||||
const current = 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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -207,6 +207,8 @@ import type {
|
||||
KilocodeRemoveCommandResponses,
|
||||
KilocodeRemoveSkillErrors,
|
||||
KilocodeRemoveSkillResponses,
|
||||
KilocodeRemoveSnapshotErrors,
|
||||
KilocodeRemoveSnapshotResponses,
|
||||
KilocodeSessionImportMessageErrors,
|
||||
KilocodeSessionImportMessageResponses,
|
||||
KilocodeSessionImportPartErrors,
|
||||
@@ -8467,6 +8469,47 @@ export class Kilocode extends HeyApiClient {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a snapshot repository
|
||||
*
|
||||
* Remove the snapshot repository for an already deleted Agent Manager worktree.
|
||||
*/
|
||||
public removeSnapshot<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
worktree?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
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
|
||||
*
|
||||
|
||||
@@ -16761,6 +16761,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
|
||||
|
||||
+184
-33
@@ -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"],
|
||||
@@ -16453,6 +16531,79 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/background-jobs/{jobID}/promote": {
|
||||
"post": {
|
||||
"tags": ["kilocode"],
|
||||
"operationId": "kilocode.backgroundJob.promote",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "jobID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Background job promoted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"description": "Background job promoted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Continue one foreground subagent in the background.",
|
||||
"summary": "Promote background job",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.backgroundJob.promote({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/anaconda-desktop/status": {
|
||||
"get": {
|
||||
"tags": ["anaconda-desktop"],
|
||||
@@ -26458,9 +26609,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSandboxStatusChanged"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventLspClientDiagnostics"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSuggestionShown"
|
||||
},
|
||||
@@ -26488,6 +26636,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventLspClientDiagnostics"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventMemoryStatus1"
|
||||
},
|
||||
@@ -29655,9 +29806,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSandboxStatusChanged"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventLspClientDiagnostics"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSuggestionShown"
|
||||
},
|
||||
@@ -29685,6 +29833,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventLspClientDiagnostics"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventMemoryStatus"
|
||||
},
|
||||
@@ -41477,33 +41628,6 @@
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventLspClientDiagnostics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["lsp.client.diagnostics"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"serverID": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["serverID", "path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventSuggestionShown": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -41800,6 +41924,33 @@
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventLspClientDiagnostics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["lsp.client.diagnostics"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"serverID": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["serverID", "path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventMemoryStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user