mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
chore: merge main and preserve browser lifecycle guards
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep Agent Manager worktree spinners active while background agents are running.
|
||||
@@ -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.
|
||||
@@ -87,6 +87,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
||||
- Avoid possibly out-of-bounds array access. Instead of `array[index] ?? {}`, use `array.at(index) ?? {}`. Instead of `array[array.length - 1]`, use `array.at(-1)`
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Prefer single word variable names where possible
|
||||
|
||||
@@ -403,6 +403,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()
|
||||
@@ -825,6 +826,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
|
||||
@@ -2371,6 +2373,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) {
|
||||
@@ -4760,6 +4763,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)
|
||||
@@ -5476,6 +5480,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()
|
||||
|
||||
@@ -45,6 +45,7 @@ import { forkSession } from "./fork-session"
|
||||
import { AgentManagerVisiblePresence } from "./am-visible-presence"
|
||||
import { continueInWorktree } from "./continue-in-worktree"
|
||||
import { WorktreeDiffController } from "./worktree-diff-controller"
|
||||
import { createWorktreeActivity } from "./worktree-activity"
|
||||
import { sendDiffBranches as postDiffBranches } from "./project/diff-branches"
|
||||
import { WorktreeImporter } from "./worktree-importer"
|
||||
import {
|
||||
@@ -108,8 +109,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined
|
||||
private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined
|
||||
private unsubTool: (() => void) | undefined
|
||||
private unsubStatus: (() => void) | undefined
|
||||
private unsubSessions: (() => void) | undefined
|
||||
private activity: ReturnType<typeof createWorktreeActivity>
|
||||
private unsubFont: (() => void) | undefined
|
||||
private unsubProjects: (() => void) | undefined
|
||||
/** Scratch set returned when no active context exists; mutations are discarded. */
|
||||
@@ -120,6 +120,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
|
||||
@@ -297,22 +298,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
(event) => (event as { type?: string }).type === "kilocode.agent_manager.start",
|
||||
(event, directory) => this.onToolEvent(event, directory),
|
||||
)
|
||||
this.unsubStatus = this.connectionService.onEventFiltered(
|
||||
(event) => (event as { type?: string }).type === "session.status",
|
||||
(event) => this.onSessionStatus(event),
|
||||
)
|
||||
this.unsubSessions = this.connectionService.onEventFiltered(
|
||||
(event) => {
|
||||
const type = (event as { type?: string }).type
|
||||
return (
|
||||
type === "session.created" ||
|
||||
type === "session.updated" ||
|
||||
type === "session.deleted" ||
|
||||
type === "session.error"
|
||||
)
|
||||
},
|
||||
(event) => this.onSessionLifecycle(event),
|
||||
)
|
||||
this.activity = createWorktreeActivity({
|
||||
connection: this.connectionService,
|
||||
paths: () =>
|
||||
[...this.contexts.values()].flatMap((ctx) => ctx.peekState()?.getWorktrees() ?? []).map((wt) => wt.path),
|
||||
post: (active) => this.postToWebview({ type: "agentManager.worktreeActivity", active }),
|
||||
status: (event) => this.onSessionStatus(event),
|
||||
lifecycle: (event) => this.onSessionLifecycle(event),
|
||||
log: (err) => this.log("Failed to load worktree activity:", err),
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Keep each project's cached sidebar session list in sync with backend
|
||||
@@ -322,6 +316,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
private onSessionLifecycle(event: unknown): void {
|
||||
handleSessionLifecycle(event, {
|
||||
busy: this.busySessions,
|
||||
removed: this.removedSessions,
|
||||
contexts: this.contexts,
|
||||
closeBrowser: (id) => this.browserLifecycle.close(id),
|
||||
post: (message) => this.postToWebview(message),
|
||||
@@ -331,7 +326,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
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)
|
||||
@@ -340,12 +335,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")
|
||||
@@ -896,6 +889,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
// instance are reaped by the router's generation guard.
|
||||
void this.terminalRouter.dispose()
|
||||
this.scripts.manager.snapshot()
|
||||
void this.activity.sync(true)
|
||||
this.log(
|
||||
`onRequestState: stateReady=${this.stateReady ? "pending" : "missing"}, state=${this.state ? "ok" : "missing"}`,
|
||||
)
|
||||
@@ -1119,7 +1113,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
req,
|
||||
)
|
||||
}
|
||||
|
||||
// Worktree actions
|
||||
|
||||
/** Create a new worktree with an auto-created first session. */
|
||||
@@ -1419,6 +1412,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
activeTarget: state.getActiveTarget(),
|
||||
...(active ? this.runStateFor(target) : {}),
|
||||
})
|
||||
void this.activity.sync()
|
||||
void pushProjectSessions(target, this.panel?.sessions, (message) => this.postToWebview(message))
|
||||
if (!active) return
|
||||
|
||||
@@ -1432,6 +1426,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
/** Push empty state when the folder is not a git repo or has no folder open. */
|
||||
private pushEmptyState(): void {
|
||||
void this.activity.sync()
|
||||
this.staleWorktreeIds.clear()
|
||||
this.postToWebview({
|
||||
type: "agentManager.state",
|
||||
@@ -1448,7 +1443,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
browserAutomation: this.host.browserAutomation(),
|
||||
})
|
||||
}
|
||||
|
||||
private get lifecycleHost(): LifecycleHost {
|
||||
return {
|
||||
createOnDisk: (opts) => this.createWorktreeOnDisk(opts),
|
||||
@@ -1458,6 +1452,11 @@ export class AgentManagerProvider implements Disposable {
|
||||
sessions: {
|
||||
register: (session) => this.panel?.sessions.registerSession(session),
|
||||
clearDirectory: (sid) => (this.browserLifecycle?.close(sid), this.panel?.sessions.clearSessionDirectory(sid)),
|
||||
setSessionDirectory: (sid, dir) => (
|
||||
this.browserLifecycle?.close(sid),
|
||||
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),
|
||||
@@ -1479,6 +1478,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),
|
||||
}
|
||||
}
|
||||
@@ -1612,6 +1612,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
private pushProjects(): void {
|
||||
const projects = this.contexts.snapshots()
|
||||
void this.activity.sync()
|
||||
this.postToWebview({
|
||||
type: "agentManager.projects",
|
||||
multiProject: this.host.multiProject(),
|
||||
@@ -1872,8 +1873,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
await this.contexts.dispose()
|
||||
await this.browserLifecycle.dispose()
|
||||
this.unsubTool?.()
|
||||
this.unsubStatus?.()
|
||||
this.unsubSessions?.()
|
||||
this.activity.dispose()
|
||||
this.unsubFont?.()
|
||||
this.unsubProjects?.()
|
||||
this.unsubDestination?.()
|
||||
|
||||
@@ -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<string, string> | undefined
|
||||
abort: (sessionIds: string[]) => Promise<void>
|
||||
forget: (sessionId: string) => void
|
||||
@@ -45,6 +52,7 @@ export interface LifecycleHost {
|
||||
acquirePtyCleanup: (directory: string) => Promise<() => void>
|
||||
metadata: (client: KiloClient, dir: string) => Promise<Record<string, unknown>>
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AgentManagerOutMessage } from "./types"
|
||||
|
||||
type Deps = {
|
||||
busy: Set<string>
|
||||
removed: Set<string>
|
||||
contexts: ProjectContexts
|
||||
closeBrowser: (sessionId: string) => void
|
||||
post: (message: AgentManagerOutMessage) => void
|
||||
@@ -14,6 +15,7 @@ type Deps = {
|
||||
type Event = { type?: string; properties?: { info?: Session; sessionID?: string } }
|
||||
|
||||
function remove(id: string, deps: Deps): void {
|
||||
deps.removed.add(id)
|
||||
deps.busy.delete(id)
|
||||
deps.closeBrowser(id)
|
||||
const ctx = deps.contexts.byLiveSession(id)
|
||||
@@ -23,6 +25,7 @@ function remove(id: string, deps: Deps): void {
|
||||
}
|
||||
|
||||
function upsert(info: Session, deps: Deps): void {
|
||||
if (deps.removed.has(info.id)) return
|
||||
const dir = info.directory
|
||||
if (!info.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return
|
||||
const ctx = deps.contexts.byDirectory(dir)
|
||||
@@ -43,9 +46,12 @@ export function handleSessionLifecycle(event: unknown, deps: Deps): void {
|
||||
return
|
||||
}
|
||||
if (ev.type === "session.deleted") {
|
||||
const id = ev.properties?.sessionID
|
||||
const id = ev.properties?.sessionID ?? ev.properties?.info?.id
|
||||
if (id) remove(id, deps)
|
||||
return
|
||||
}
|
||||
if (ev.properties?.info) upsert(ev.properties.info, deps)
|
||||
const info = ev.properties?.info
|
||||
if (!info) return
|
||||
if (ev.type === "session.created") deps.removed.delete(info.id)
|
||||
upsert(info, deps)
|
||||
}
|
||||
|
||||
@@ -129,6 +129,11 @@ interface WorktreeStatsMessage {
|
||||
stats: WorktreeStats[]
|
||||
}
|
||||
|
||||
interface WorktreeActivityMessage {
|
||||
type: "agentManager.worktreeActivity"
|
||||
active: string[]
|
||||
}
|
||||
|
||||
interface LocalStatsMessage {
|
||||
type: "agentManager.localStats"
|
||||
/** Owning project; absent in single-project mode. */
|
||||
@@ -261,6 +266,9 @@ interface ScriptTerminalsMessage {
|
||||
interface ErrorOutMessage {
|
||||
type: "error"
|
||||
message: string
|
||||
code?: string
|
||||
projectId?: string
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
interface SessionAddedMessage {
|
||||
@@ -498,6 +506,7 @@ interface RunStatusMessage extends RunStatus {
|
||||
|
||||
/** All messages the Agent Manager extension sends to the webview. */
|
||||
export type AgentManagerOutMessage =
|
||||
| WorktreeActivityMessage
|
||||
| WorktreeStatsMessage
|
||||
| LocalStatsMessage
|
||||
| WorktreeSetupMessage
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { samePath } from "./project/paths"
|
||||
import type { KiloConnectionService } from "../services/cli-backend"
|
||||
|
||||
type Snapshot = {
|
||||
statuses: Record<string, { type: string }>
|
||||
permissions: Array<{ id: string; sessionID: string }>
|
||||
questions: Array<{ id: string; sessionID: string; blocking?: boolean }>
|
||||
}
|
||||
|
||||
type Change =
|
||||
| { kind: "status"; sessionID: string; type: string }
|
||||
| { kind: "permission.add"; id: string; sessionID: string }
|
||||
| { kind: "permission.remove"; id: string; sessionID: string }
|
||||
| { kind: "question.add"; id: string; sessionID: string }
|
||||
| { kind: "question.remove"; id: string; sessionID: string }
|
||||
| { kind: "clear"; sessionID: string }
|
||||
|
||||
type State = {
|
||||
dir: string
|
||||
loaded: boolean
|
||||
statuses: Map<string, string>
|
||||
permissions: Map<string, string>
|
||||
questions: Map<string, string>
|
||||
request?: Request
|
||||
}
|
||||
|
||||
type Request = {
|
||||
readonly state: State
|
||||
readonly events: Change[]
|
||||
readonly promise: Promise<void>
|
||||
}
|
||||
|
||||
type Options = {
|
||||
paths: () => string[]
|
||||
load: (dir: string) => Promise<Snapshot>
|
||||
post: (active: string[]) => void
|
||||
log: (err: unknown) => void
|
||||
}
|
||||
|
||||
type FactoryOptions = {
|
||||
connection: KiloConnectionService
|
||||
paths: () => string[]
|
||||
post: (active: string[]) => void
|
||||
status: (event: unknown) => void
|
||||
lifecycle: (event: unknown) => void
|
||||
log: (err: unknown) => void
|
||||
}
|
||||
|
||||
const TYPES = new Set([
|
||||
"session.status",
|
||||
"session.deleted",
|
||||
"session.error",
|
||||
"permission.asked",
|
||||
"permission.replied",
|
||||
"question.asked",
|
||||
"question.replied",
|
||||
"question.rejected",
|
||||
"server.instance.disposed",
|
||||
])
|
||||
|
||||
function normalize(dir: string): string {
|
||||
const value = dir.replace(/\\/g, "/")
|
||||
if (/^[A-Za-z]:\/+$/u.test(value)) return `${value.slice(0, 2)}/`
|
||||
const result = value.replace(/\/+$/u, "")
|
||||
return result || "/"
|
||||
}
|
||||
|
||||
function matches(a: string, b: string): boolean {
|
||||
return samePath(normalize(a), normalize(b))
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
function string(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export class WorktreeActivity {
|
||||
private states: State[] = []
|
||||
private cache: string[] = []
|
||||
private dead = false
|
||||
|
||||
constructor(private readonly opts: Options) {}
|
||||
|
||||
static accepts(event: unknown): boolean {
|
||||
const value = record(event)
|
||||
return value !== undefined && typeof value.type === "string" && TYPES.has(value.type)
|
||||
}
|
||||
|
||||
async sync(force = false): Promise<void> {
|
||||
if (this.dead) return
|
||||
|
||||
let changed = false
|
||||
const wanted: State[] = []
|
||||
const current = this.states
|
||||
for (const dir of this.opts.paths()) {
|
||||
const state = this.find(current, wanted, dir)
|
||||
if (!state) continue
|
||||
if (wanted.includes(state)) continue
|
||||
if (state.dir !== dir) changed = true
|
||||
state.dir = dir
|
||||
wanted.push(state)
|
||||
}
|
||||
|
||||
changed = this.prune(current, wanted) || changed
|
||||
if (wanted.length !== current.length) changed = true
|
||||
this.states = wanted
|
||||
|
||||
const jobs: Promise<void>[] = []
|
||||
changed = this.load(wanted, force, jobs) || changed
|
||||
await Promise.all(jobs)
|
||||
if (changed) this.publish()
|
||||
}
|
||||
|
||||
replay(): void {
|
||||
if (this.dead) return
|
||||
this.opts.post(this.cache.slice())
|
||||
}
|
||||
|
||||
event(event: unknown, directory?: string): void {
|
||||
if (this.dead || !WorktreeActivity.accepts(event)) return
|
||||
const value = record(event)
|
||||
if (!value) return
|
||||
|
||||
const type = value.type
|
||||
if (type === "server.instance.disposed") {
|
||||
const props = record(value.properties)
|
||||
const dir = string(props?.directory) ?? directory
|
||||
if (!dir) return
|
||||
const state = this.states.find((item) => matches(item.dir, dir))
|
||||
if (!state) return
|
||||
this.invalidate(state)
|
||||
state.loaded = false
|
||||
this.publish()
|
||||
return
|
||||
}
|
||||
if (!directory) return
|
||||
const state = this.states.find((item) => matches(item.dir, directory))
|
||||
if (!state) return
|
||||
|
||||
const change = this.change(type, value.properties)
|
||||
if (!change) return
|
||||
if (state.request) state.request.events.push(change)
|
||||
this.apply(state, change)
|
||||
this.publish()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.dead) return
|
||||
for (const state of this.states) this.invalidate(state)
|
||||
this.states = []
|
||||
this.cache = []
|
||||
this.opts.post([])
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.dead) return
|
||||
this.dead = true
|
||||
for (const state of this.states) this.invalidate(state)
|
||||
this.states = []
|
||||
this.cache = []
|
||||
}
|
||||
|
||||
private state(dir: string): State {
|
||||
return {
|
||||
dir,
|
||||
loaded: false,
|
||||
statuses: new Map(),
|
||||
permissions: new Map(),
|
||||
questions: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
private find(current: State[], wanted: State[], dir: unknown): State | undefined {
|
||||
if (typeof dir !== "string" || !dir) return undefined
|
||||
return (
|
||||
current.find((item) => matches(item.dir, dir)) ?? wanted.find((item) => matches(item.dir, dir)) ?? this.state(dir)
|
||||
)
|
||||
}
|
||||
|
||||
private prune(current: State[], wanted: State[]): boolean {
|
||||
let changed = false
|
||||
for (const state of current) {
|
||||
if (wanted.includes(state)) continue
|
||||
this.invalidate(state)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private load(wanted: State[], force: boolean, jobs: Promise<void>[]): boolean {
|
||||
let changed = false
|
||||
for (const state of wanted) {
|
||||
const req = state.request ?? (!state.loaded || force ? this.request(state) : undefined)
|
||||
if (req) jobs.push(req.promise)
|
||||
if (req || !state.loaded || force) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private request(state: State): Request {
|
||||
const req: Request = {
|
||||
state,
|
||||
events: [],
|
||||
promise: Promise.resolve()
|
||||
.then(() => this.opts.load(state.dir))
|
||||
.then((snapshot) => this.finish(req, snapshot))
|
||||
.catch((err: unknown) => {
|
||||
if (this.valid(req)) req.state.loaded = false
|
||||
this.opts.log(err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.request === req) state.request = undefined
|
||||
}),
|
||||
}
|
||||
state.request = req
|
||||
return req
|
||||
}
|
||||
|
||||
private finish(req: Request, snapshot: Snapshot): void {
|
||||
if (!this.valid(req)) return
|
||||
const next = this.state(req.state.dir)
|
||||
for (const [sessionID, status] of Object.entries(snapshot.statuses ?? {})) {
|
||||
if (typeof status?.type === "string") next.statuses.set(sessionID, status.type)
|
||||
}
|
||||
for (const item of snapshot.permissions ?? []) {
|
||||
if (typeof item?.id === "string" && typeof item.sessionID === "string")
|
||||
next.permissions.set(item.id, item.sessionID)
|
||||
}
|
||||
for (const item of snapshot.questions ?? []) {
|
||||
if (item?.blocking !== false && typeof item?.id === "string" && typeof item.sessionID === "string")
|
||||
next.questions.set(item.id, item.sessionID)
|
||||
}
|
||||
for (const change of req.events) this.apply(next, change)
|
||||
Object.assign(req.state, {
|
||||
statuses: next.statuses,
|
||||
permissions: next.permissions,
|
||||
questions: next.questions,
|
||||
loaded: true,
|
||||
})
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private valid(req: Request): boolean {
|
||||
return !this.dead && req.state.request === req && this.states.includes(req.state)
|
||||
}
|
||||
|
||||
private invalidate(state: State): void {
|
||||
state.request = undefined
|
||||
state.loaded = false
|
||||
state.statuses.clear()
|
||||
state.permissions.clear()
|
||||
state.questions.clear()
|
||||
}
|
||||
|
||||
private change(type: unknown, props: unknown): Change | undefined {
|
||||
const value = record(props)
|
||||
if (!value) return undefined
|
||||
if (type === "session.status") return this.status(value)
|
||||
if (type === "session.deleted" || type === "session.error") return this.cleared(value)
|
||||
if (type === "permission.asked" || type === "question.asked") return this.add(type, value)
|
||||
if (type === "permission.replied" || type === "question.replied" || type === "question.rejected")
|
||||
return this.remove(type, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
private status(value: Record<string, unknown>): Change | undefined {
|
||||
const sessionID = string(value.sessionID)
|
||||
const status = record(value.status)
|
||||
const type = string(status?.type)
|
||||
return sessionID && type ? { kind: "status", sessionID, type } : undefined
|
||||
}
|
||||
|
||||
private cleared(value: Record<string, unknown>): Change | undefined {
|
||||
const info = record(value.info)
|
||||
const sessionID = string(value.sessionID) ?? string(info?.id)
|
||||
return sessionID ? { kind: "clear", sessionID } : undefined
|
||||
}
|
||||
|
||||
private add(type: unknown, value: Record<string, unknown>): Change | undefined {
|
||||
const id = string(value.id)
|
||||
const sessionID = string(value.sessionID)
|
||||
if (!id || !sessionID) return undefined
|
||||
if (type === "question.asked" && value.blocking === false) return { kind: "question.remove", id, sessionID }
|
||||
return type === "permission.asked"
|
||||
? { kind: "permission.add", id, sessionID }
|
||||
: { kind: "question.add", id, sessionID }
|
||||
}
|
||||
|
||||
private remove(type: unknown, value: Record<string, unknown>): Change | undefined {
|
||||
const id = string(value.requestID)
|
||||
const sessionID = string(value.sessionID)
|
||||
if (!id || !sessionID) return undefined
|
||||
return type === "permission.replied"
|
||||
? { kind: "permission.remove", id, sessionID }
|
||||
: { kind: "question.remove", id, sessionID }
|
||||
}
|
||||
|
||||
private apply(state: State, change: Change): void {
|
||||
if (change.kind === "status") {
|
||||
state.statuses.set(change.sessionID, change.type)
|
||||
return
|
||||
}
|
||||
if (change.kind === "permission.add") {
|
||||
state.permissions.set(change.id, change.sessionID)
|
||||
return
|
||||
}
|
||||
if (change.kind === "permission.remove") {
|
||||
if (state.permissions.get(change.id) === change.sessionID) state.permissions.delete(change.id)
|
||||
return
|
||||
}
|
||||
if (change.kind === "question.add") {
|
||||
state.questions.set(change.id, change.sessionID)
|
||||
return
|
||||
}
|
||||
if (change.kind === "question.remove") {
|
||||
if (state.questions.get(change.id) === change.sessionID) state.questions.delete(change.id)
|
||||
return
|
||||
}
|
||||
state.statuses.delete(change.sessionID)
|
||||
for (const [id, sessionID] of state.permissions) {
|
||||
if (sessionID === change.sessionID) state.permissions.delete(id)
|
||||
}
|
||||
for (const [id, sessionID] of state.questions) {
|
||||
if (sessionID === change.sessionID) state.questions.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private active(state: State): boolean {
|
||||
const blocked = new Set([...state.permissions.values(), ...state.questions.values()])
|
||||
for (const [sessionID, type] of state.statuses) {
|
||||
if ((type === "busy" || type === "retry") && !blocked.has(sessionID)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
if (this.dead) return
|
||||
const active = this.states.filter((state) => this.active(state)).map((state) => state.dir)
|
||||
this.cache = active.slice()
|
||||
this.opts.post(active)
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorktreeActivity(opts: FactoryOptions) {
|
||||
const activity = new WorktreeActivity({
|
||||
paths: opts.paths,
|
||||
load: async (dir) => {
|
||||
const client = opts.connection.getClient()
|
||||
const [status, permission, question] = await Promise.all([
|
||||
client.session.status({ directory: dir }, { throwOnError: true }),
|
||||
client.permission.list({ directory: dir }, { throwOnError: true }),
|
||||
client.question.list({ directory: dir }, { throwOnError: true }),
|
||||
])
|
||||
return {
|
||||
statuses: status.data ?? {},
|
||||
permissions: permission.data ?? [],
|
||||
questions: question.data ?? [],
|
||||
}
|
||||
},
|
||||
post: opts.post,
|
||||
log: opts.log,
|
||||
})
|
||||
const filter = (event: unknown) => {
|
||||
const type = record(event)?.type
|
||||
return WorktreeActivity.accepts(event) || type === "session.created" || type === "session.updated"
|
||||
}
|
||||
const unsubEvent = opts.connection.onEventFiltered(filter, (event, directory) => {
|
||||
activity.event(event, directory)
|
||||
const type = record(event)?.type
|
||||
if (type === "session.status") opts.status(event)
|
||||
if (
|
||||
type === "session.created" ||
|
||||
type === "session.updated" ||
|
||||
type === "session.deleted" ||
|
||||
type === "session.error"
|
||||
)
|
||||
opts.lifecycle(event)
|
||||
})
|
||||
const sync = (force = false) => {
|
||||
if (force) activity.replay()
|
||||
if (opts.connection.getConnectionState() !== "connected") return Promise.resolve()
|
||||
return activity.sync(force)
|
||||
}
|
||||
const unsubState = opts.connection.onStateChange((state) => {
|
||||
if (state !== "connected") return activity.clear()
|
||||
void sync(true)
|
||||
})
|
||||
return {
|
||||
sync,
|
||||
dispose: () => {
|
||||
unsubEvent()
|
||||
unsubState()
|
||||
activity.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -657,12 +657,26 @@ 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")
|
||||
const helper = fs.readFileSync(path.join(ROOT, "src/agent-manager/session-lifecycle.ts"), "utf-8")
|
||||
|
||||
expect(lifecycle).toContain("removed: this.removedSessions")
|
||||
expect(lifecycle).toContain("busy: this.busySessions")
|
||||
expect(helper).toContain("deps.removed.add(id)")
|
||||
expect(helper).toContain("deps.busy.delete(id)")
|
||||
expect(helper).toContain("if (deps.removed.has(info.id)) return")
|
||||
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 -------------------------------------------
|
||||
|
||||
@@ -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<typeof mock>; delete: ReturnType<typeof mock> }
|
||||
permission: { list: ReturnType<typeof mock> }
|
||||
question: { list: ReturnType<typeof mock> }
|
||||
experimental: { session: { list: ReturnType<typeof mock> }; controlPlane: { moveSession: 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: [] })) },
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
|
||||
@@ -226,6 +226,7 @@ type ProviderInternals = {
|
||||
sessionDirectories: Map<string, string>
|
||||
sessionStatusMap: Map<string, string>
|
||||
trackedSessionIds: Set<string>
|
||||
removedSessionIds: Set<string>
|
||||
openSessionIds: Set<string>
|
||||
draftSessions: Map<string, { sid: string; dir: string; expires: number }>
|
||||
checkpoints: Map<string, Promise<void>>
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createSessionBusy } from "../../webview-ui/agent-manager/project/session-busy"
|
||||
import { createSessionBusy, createWorktreeBusy } from "../../webview-ui/agent-manager/project/session-busy"
|
||||
import type { ExtensionMessage } from "../../webview-ui/src/types/messages"
|
||||
|
||||
const busy = (statuses: Record<string, { type: string }>) =>
|
||||
createSessionBusy({
|
||||
statuses: () => statuses,
|
||||
permissions: () => [],
|
||||
questions: () => [],
|
||||
managed: () => [
|
||||
{ id: "unknown", worktreeId: "wt-unknown" },
|
||||
{ id: "idle", worktreeId: "wt-idle" },
|
||||
{ id: "working", worktreeId: "wt-working" },
|
||||
],
|
||||
local: () => [],
|
||||
projects: () => ({ background: [{ id: "unknown", worktreeId: "wt-unknown" }] }),
|
||||
active: () => "project-a",
|
||||
})
|
||||
const options = (statuses: Record<string, { type: string }>) => ({
|
||||
statuses: () => statuses,
|
||||
permissions: () => [],
|
||||
questions: () => [],
|
||||
managed: () => [
|
||||
{ id: "unknown", worktreeId: "wt-unknown" },
|
||||
{ id: "idle", worktreeId: "wt-idle" },
|
||||
{ id: "working", worktreeId: "wt-working" },
|
||||
],
|
||||
local: () => [],
|
||||
projects: () => ({ background: [{ id: "unknown", worktreeId: "wt-unknown" }] }),
|
||||
active: () => "project-a",
|
||||
})
|
||||
const busy = (statuses: Record<string, { type: string }>) => createSessionBusy(options(statuses))
|
||||
|
||||
describe("createSessionBusy", () => {
|
||||
it("does not mark stopped or unknown sessions as busy", () => {
|
||||
@@ -25,7 +26,90 @@ describe("createSessionBusy", () => {
|
||||
expect(state.project("background", "wt-unknown")).toBe(false)
|
||||
})
|
||||
|
||||
it("marks sessions with an active status as busy", () => {
|
||||
expect(busy({ working: { type: "busy" } }).agent("wt-working")).toBe(true)
|
||||
it.each(["busy", "retry"])("marks sessions with an active %s status as busy", (type) => {
|
||||
expect(busy({ working: { type } }).agent("wt-working")).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps running for non-blocking questions", () => {
|
||||
const questions: { sessionID: string; blocking?: boolean }[] = [{ sessionID: "working", blocking: false }]
|
||||
const state = createSessionBusy({
|
||||
...options({ working: { type: "busy" } }),
|
||||
questions: () => questions,
|
||||
})
|
||||
expect(state.agent("wt-working")).toBe(true)
|
||||
questions[0].blocking = true
|
||||
expect(state.agent("wt-working")).toBe(false)
|
||||
delete questions[0].blocking
|
||||
expect(state.agent("wt-working")).toBe(false)
|
||||
})
|
||||
|
||||
it("does not keep a spinner for an offline session", () => {
|
||||
const state = busy({ working: { type: "offline" }, unknown: { type: "offline" } })
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createWorktreeBusy", () => {
|
||||
it("keeps directory activity separate from parent status and other projects", () => {
|
||||
const listeners = new Set<(message: ExtensionMessage) => void>()
|
||||
const state = createWorktreeBusy({
|
||||
...options({ idle: { type: "idle" }, working: { type: "busy" } }),
|
||||
worktrees: (project) => [
|
||||
{ id: "wt-idle", path: project === "background" ? "/other/worktree" : "/repo/worktree" },
|
||||
],
|
||||
subscribe: (callback) => {
|
||||
listeners.add(callback)
|
||||
return () => listeners.delete(callback)
|
||||
},
|
||||
})
|
||||
const send = (active: string[]) => {
|
||||
for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active })
|
||||
}
|
||||
|
||||
expect(state.agent("wt-idle")).toBe(false)
|
||||
expect(state.agent("wt-working")).toBe(true)
|
||||
send(["/repo/worktree"])
|
||||
expect(state.agent("wt-idle")).toBe(true)
|
||||
expect(state.project("project-a", "wt-idle")).toBe(true)
|
||||
expect(state.project("background", "wt-idle")).toBe(false)
|
||||
expect(state.project("background", null)).toBe(false)
|
||||
expect(state.agent("missing")).toBe(false)
|
||||
expect(state.session("idle")).toBe(false)
|
||||
expect(state.local()).toBe(false)
|
||||
|
||||
send(["/other/worktree"])
|
||||
expect(state.agent("wt-idle")).toBe(false)
|
||||
expect(state.project("background", "wt-idle")).toBe(true)
|
||||
send([])
|
||||
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)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { handleSessionLifecycle } from "../../src/agent-manager/session-lifecycle"
|
||||
import type { ProjectContexts } from "../../src/agent-manager/project/contexts"
|
||||
import type { AgentManagerOutMessage } from "../../src/agent-manager/types"
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
|
||||
const info: Session = {
|
||||
id: "session",
|
||||
slug: "test-session",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Browser session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
|
||||
describe("session lifecycle merge integration", () => {
|
||||
test.each(["sessionID", "info"])("preserves deletion guards and browser cleanup for %s events", (shape) => {
|
||||
const sessions: Array<{ id: string }> = []
|
||||
const closed: string[] = []
|
||||
const posted: AgentManagerOutMessage[] = []
|
||||
const context = {
|
||||
id: "project",
|
||||
lifecycle: "ready",
|
||||
peekState: () => undefined,
|
||||
sessions: () => sessions,
|
||||
upsertSession: (session: { id: string }) => sessions.push(session),
|
||||
removeLiveSession: (id: string) =>
|
||||
sessions.splice(
|
||||
sessions.findIndex((session) => session.id === id),
|
||||
1,
|
||||
),
|
||||
invalidateSessions: () => {},
|
||||
}
|
||||
const deps = {
|
||||
busy: new Set([info.id]),
|
||||
removed: new Set<string>(),
|
||||
contexts: {
|
||||
byDirectory: () => context,
|
||||
byLiveSession: () => context,
|
||||
} as unknown as ProjectContexts,
|
||||
closeBrowser: (id: string) => closed.push(id),
|
||||
post: (message: AgentManagerOutMessage) => posted.push(message),
|
||||
}
|
||||
handleSessionLifecycle({ type: "session.created", properties: { info } }, deps)
|
||||
expect(sessions).toHaveLength(1)
|
||||
handleSessionLifecycle(
|
||||
{ type: "session.deleted", properties: shape === "info" ? { info } : { sessionID: info.id } },
|
||||
deps,
|
||||
)
|
||||
expect(closed).toEqual([info.id])
|
||||
expect(deps.removed.has(info.id)).toBe(true)
|
||||
expect(deps.busy.has(info.id)).toBe(false)
|
||||
expect(sessions).toHaveLength(0)
|
||||
const count = posted.length
|
||||
handleSessionLifecycle({ type: "session.updated", properties: { info } }, deps)
|
||||
expect(sessions).toHaveLength(0)
|
||||
expect(posted).toHaveLength(count)
|
||||
handleSessionLifecycle({ type: "session.created", properties: { info } }, deps)
|
||||
expect(deps.removed.has(info.id)).toBe(false)
|
||||
expect(sessions).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,476 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createWorktreeActivity, WorktreeActivity } from "../../src/agent-manager/worktree-activity"
|
||||
|
||||
type Snapshot = {
|
||||
statuses: Record<string, { type: string }>
|
||||
permissions: Array<{ id: string; sessionID: string }>
|
||||
questions: Array<{ id: string; sessionID: string; blocking?: boolean }>
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
return Promise.withResolvers<T>()
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
statuses: Record<string, string> = {},
|
||||
permissions: string[][] = [],
|
||||
questions: string[][] = [],
|
||||
): Snapshot {
|
||||
return {
|
||||
statuses: Object.fromEntries(Object.entries(statuses).map(([id, type]) => [id, { type }])),
|
||||
permissions: permissions.map(([id, sessionID]) => ({ id, sessionID })),
|
||||
questions: questions.map(([id, sessionID]) => ({ id, sessionID })),
|
||||
}
|
||||
}
|
||||
|
||||
function status(sessionID: string, type: string) {
|
||||
return { type: "session.status", properties: { sessionID, status: { type } } }
|
||||
}
|
||||
|
||||
function asked(type: "permission" | "question", id: string, sessionID: string) {
|
||||
return { type: `${type}.asked`, properties: { id, sessionID } }
|
||||
}
|
||||
|
||||
function replied(type: "permission" | "question", requestID: string, sessionID: string, kind = "replied") {
|
||||
return { type: `${type}.${kind}`, properties: { requestID, sessionID } }
|
||||
}
|
||||
|
||||
function setup(dirs: string[], load: (dir: string) => Promise<Snapshot>) {
|
||||
const posted: string[][] = []
|
||||
const errors: unknown[] = []
|
||||
const activity = new WorktreeActivity({
|
||||
paths: () => dirs,
|
||||
load,
|
||||
post: (active) => posted.push(active),
|
||||
log: (err) => errors.push(err),
|
||||
})
|
||||
return { activity, posted, errors }
|
||||
}
|
||||
|
||||
function connection(client: unknown, state = "connected") {
|
||||
let stateListener: ((value: string) => void) | undefined
|
||||
let eventListener: ((event: unknown, directory?: string) => void) | undefined
|
||||
let eventFilter: ((event: unknown) => boolean) | undefined
|
||||
const value = {
|
||||
getClient: () => client,
|
||||
getConnectionState: () => state,
|
||||
onStateChange: (listener: (value: string) => void) => {
|
||||
stateListener = listener
|
||||
return () => {
|
||||
stateListener = undefined
|
||||
}
|
||||
},
|
||||
onEventFiltered: (filter: (event: unknown) => boolean, listener: (event: unknown, directory?: string) => void) => {
|
||||
eventFilter = filter
|
||||
eventListener = listener
|
||||
return () => {
|
||||
eventFilter = undefined
|
||||
eventListener = undefined
|
||||
}
|
||||
},
|
||||
change: (next: string) => {
|
||||
state = next
|
||||
stateListener?.(next)
|
||||
},
|
||||
set: (next: string) => {
|
||||
state = next
|
||||
},
|
||||
emit: (event: unknown, directory?: string) => {
|
||||
if (eventFilter?.(event)) eventListener?.(event, directory)
|
||||
},
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
describe("WorktreeActivity", () => {
|
||||
it("accepts only relevant SDK events", () => {
|
||||
expect(WorktreeActivity.accepts({ type: "session.status" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "session.deleted" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "session.error" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "permission.asked" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "permission.replied" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "question.asked" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "question.replied" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "question.rejected" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "server.instance.disposed" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "session.created" })).toBe(false)
|
||||
expect(WorktreeActivity.accepts(null)).toBe(false)
|
||||
expect(WorktreeActivity.accepts("session.status")).toBe(false)
|
||||
})
|
||||
|
||||
it("counts busy and retry children while ignoring idle and offline sessions", async () => {
|
||||
const test = setup(["/repo"], async () => snapshot({ parent: "idle", child: "busy" }))
|
||||
await test.activity.sync()
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
test.activity.event(status("child", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
test.activity.event(status("parent", "retry"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("parent", "offline"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("excludes blocked children without suppressing active siblings", async () => {
|
||||
const test = setup(["/repo"], async () => snapshot({ child: "busy", sibling: "idle" }))
|
||||
await test.activity.sync()
|
||||
|
||||
test.activity.event(asked("permission", "p1", "child"), "/repo")
|
||||
test.activity.event(asked("permission", "p2", "child"), "/repo")
|
||||
test.activity.event(asked("question", "q1", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(status("sibling", "busy"), "/repo")
|
||||
test.activity.event(replied("permission", "p1", "child"), "/repo")
|
||||
test.activity.event(replied("question", "q1", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
test.activity.event(replied("permission", "p2", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("sibling", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("child", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(asked("question", "q2", "child"), "/repo")
|
||||
test.activity.event(replied("question", "q2", "child", "rejected"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("ignores non-blocking questions in snapshots and live events", async () => {
|
||||
const test = setup(["/repo"], async () => ({
|
||||
...snapshot({ child: "busy" }),
|
||||
questions: [{ id: "note", sessionID: "child", blocking: false }],
|
||||
}))
|
||||
await test.activity.sync()
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
const question = asked("question", "live", "child")
|
||||
test.activity.event({ ...question, properties: { ...question.properties, blocking: false } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(question, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
test.activity.event({ ...question, properties: { ...question.properties, blocking: false } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
})
|
||||
|
||||
it("clears sessions on completion, deletion, errors, and offline status", async () => {
|
||||
const test = setup(["/repo"], async () => snapshot())
|
||||
await test.activity.sync()
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event(status("s1", "complete"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event({ type: "session.error", properties: { sessionID: "s1" } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event({ type: "session.deleted", properties: { info: { id: "s1" } } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event(status("s1", "offline"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("isolates normalized directories and ignores unknown ownership", async () => {
|
||||
const dirs = ["/repo/a/", "/repo/b"]
|
||||
const test = setup(dirs, async () => snapshot())
|
||||
await test.activity.sync()
|
||||
|
||||
test.activity.event(status("a1", "busy"), "\\repo\\a\\")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a/"])
|
||||
test.activity.event(status("b1", "busy"), "/repo/b/")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a/", "/repo/b"])
|
||||
test.activity.event(status("unknown", "busy"), "/repo/unknown")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a/", "/repo/b"])
|
||||
})
|
||||
|
||||
it("deduplicates loads, hydrates new paths, force refreshes, and replays cached output", async () => {
|
||||
const dirs = ["/repo"]
|
||||
const firstGate = defer<Snapshot>()
|
||||
const forceGate = defer<Snapshot>()
|
||||
const newGate = defer<Snapshot>()
|
||||
const gates = [firstGate, forceGate, newGate]
|
||||
const calls: string[] = []
|
||||
const test = setup(dirs, (dir) => {
|
||||
calls.push(dir)
|
||||
const gate = gates.shift()
|
||||
if (!gate) return Promise.resolve(snapshot())
|
||||
return gate.promise
|
||||
})
|
||||
|
||||
const first = test.activity.sync()
|
||||
const second = test.activity.sync()
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toEqual(["/repo"])
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
firstGate.resolve(snapshot({ s1: "busy" }))
|
||||
await Promise.all([first, second])
|
||||
expect(calls).toEqual(["/repo"])
|
||||
|
||||
test.activity.replay()
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
const force = test.activity.sync(true)
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toEqual(["/repo", "/repo"])
|
||||
forceGate.resolve(snapshot())
|
||||
await force
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
dirs.push("/repo/new")
|
||||
const add = test.activity.sync()
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toEqual(["/repo", "/repo", "/repo/new"])
|
||||
newGate.resolve(snapshot({ newSession: "retry" }))
|
||||
await add
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/new"])
|
||||
})
|
||||
|
||||
it("defers the loader and recovers from synchronous failures", async () => {
|
||||
const error = new Error("load failed")
|
||||
let calls = 0
|
||||
const test = setup(["/repo"], () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw error
|
||||
return Promise.resolve(snapshot({ child: "busy" }))
|
||||
})
|
||||
const pending = test.activity.sync()
|
||||
expect(calls).toBe(0)
|
||||
await pending
|
||||
expect(test.errors).toEqual([error])
|
||||
|
||||
await test.activity.sync()
|
||||
expect(calls).toBe(2)
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
await test.activity.sync()
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it("commits snapshots to the tracked state used by later events and refreshes", async () => {
|
||||
const initial = snapshot({ child: "busy" }, [["p1", "child"]], [["q1", "child"]])
|
||||
const snapshots = [initial, snapshot({ child: "retry" })]
|
||||
const test = setup(["/repo"], async () => snapshots.shift()!)
|
||||
await test.activity.sync()
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(replied("permission", "p1", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
test.activity.event(replied("question", "q1", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
await test.activity.sync(true)
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("child", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
expect(initial).toEqual(snapshot({ child: "busy" }, [["p1", "child"]], [["q1", "child"]]))
|
||||
expect(test.errors).toEqual([])
|
||||
})
|
||||
|
||||
it("does not let a snapshot overwrite newer events or remove other active children", async () => {
|
||||
const gate = defer<Snapshot>()
|
||||
const test = setup(["/repo"], () => gate.promise)
|
||||
const pending = test.activity.sync()
|
||||
test.activity.event(status("one", "idle"), "/repo")
|
||||
test.activity.event(status("two", "idle"), "/repo")
|
||||
test.activity.event(status("one", "busy"), "/repo")
|
||||
test.activity.event(status("two", "busy"), "/repo")
|
||||
test.activity.event(status("one", "idle"), "/repo")
|
||||
gate.resolve(snapshot({ one: "busy", two: "busy" }))
|
||||
await pending
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("two", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("prunes removed paths and prevents old loads from reviving activity", async () => {
|
||||
const dirs = ["/repo"]
|
||||
const gate = defer<Snapshot>()
|
||||
const test = setup(dirs, () => gate.promise)
|
||||
const pending = test.activity.sync()
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
dirs.length = 0
|
||||
await test.activity.sync()
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
gate.resolve(snapshot({ s1: "busy" }))
|
||||
await pending
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("clears on disconnect and disposal, then allows a fresh sync", async () => {
|
||||
const dirs = ["/repo"]
|
||||
const firstGate = defer<Snapshot>()
|
||||
const nextGate = defer<Snapshot>()
|
||||
const gates = [firstGate, nextGate]
|
||||
const test = setup(dirs, () => gates.shift()!.promise)
|
||||
const first = test.activity.sync()
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event({ type: "server.instance.disposed", properties: { directory: "/repo" } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
firstGate.resolve(snapshot({ s1: "busy" }))
|
||||
await first
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.clear()
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
const next = test.activity.sync()
|
||||
nextGate.resolve(snapshot({ s2: "busy" }))
|
||||
await next
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
test.activity.dispose()
|
||||
const count = test.posted.length
|
||||
test.activity.event(status("s3", "busy"), "/repo")
|
||||
await test.activity.sync()
|
||||
test.activity.replay()
|
||||
expect(test.posted).toHaveLength(count)
|
||||
})
|
||||
|
||||
it("logs failed paths without erasing successful siblings and retries them", async () => {
|
||||
const dirs = ["/repo/a", "/repo/b"]
|
||||
const a = defer<Snapshot>()
|
||||
const b = defer<Snapshot>()
|
||||
const retry = defer<Snapshot>()
|
||||
const calls: string[] = []
|
||||
const test = setup(dirs, (dir) => {
|
||||
calls.push(dir)
|
||||
if (dir === "/repo/a") return a.promise
|
||||
if (calls.filter((item) => item === "/repo/b").length === 1) return b.promise
|
||||
return retry.promise
|
||||
})
|
||||
const pending = test.activity.sync()
|
||||
a.resolve(snapshot({ a1: "busy" }))
|
||||
b.reject(new Error("b failed"))
|
||||
await pending
|
||||
expect(test.errors).toHaveLength(1)
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a"])
|
||||
expect(calls).toEqual(["/repo/a", "/repo/b"])
|
||||
|
||||
const again = test.activity.sync()
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toEqual(["/repo/a", "/repo/b", "/repo/b"])
|
||||
retry.resolve(snapshot())
|
||||
await again
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a"])
|
||||
})
|
||||
|
||||
it("does not replay failed-load events over a newer recovery snapshot", async () => {
|
||||
const first = defer<Snapshot>()
|
||||
const next = defer<Snapshot>()
|
||||
const gates = [first, next]
|
||||
const test = setup(["/repo"], () => gates.shift()!.promise)
|
||||
const pending = test.activity.sync()
|
||||
test.activity.event(status("child", "busy"), "/repo")
|
||||
first.reject(new Error("snapshot failed"))
|
||||
await pending
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
const recovery = test.activity.sync(true)
|
||||
next.resolve(snapshot())
|
||||
await recovery
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("publishes a ready worktree while another snapshot is still loading", async () => {
|
||||
const gate = defer<Snapshot>()
|
||||
const ready = defer<string[]>()
|
||||
const activity = new WorktreeActivity({
|
||||
paths: () => ["/fast", "/slow"],
|
||||
load: async (dir) => (dir === "/fast" ? snapshot({ child: "busy" }) : gate.promise),
|
||||
post: (active) => ready.resolve(active),
|
||||
log: (err) => {
|
||||
throw err
|
||||
},
|
||||
})
|
||||
const pending = activity.sync()
|
||||
expect(await ready.promise).toEqual(["/fast"])
|
||||
gate.resolve(snapshot())
|
||||
await pending
|
||||
activity.dispose()
|
||||
})
|
||||
|
||||
it("wires the activity wrapper to the connection without loading histories", async () => {
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
status: async (input: { directory: string }, options: { throwOnError: true }) => {
|
||||
calls.push(`status:${input.directory}:${options.throwOnError}`)
|
||||
return { data: { s1: { type: "busy" } } }
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
list: async (input: { directory: string }, options: { throwOnError: true }) => {
|
||||
calls.push(`permission:${input.directory}:${options.throwOnError}`)
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
question: {
|
||||
list: async (input: { directory: string }, options: { throwOnError: true }) => {
|
||||
calls.push(`question:${input.directory}:${options.throwOnError}`)
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
}
|
||||
const conn = connection(client)
|
||||
const posted: string[][] = []
|
||||
const statuses: unknown[] = []
|
||||
const lifecycle: unknown[] = []
|
||||
const wrapper = createWorktreeActivity({
|
||||
connection: conn as never,
|
||||
paths: () => ["/repo"],
|
||||
post: (active) => posted.push(active),
|
||||
status: (event) => statuses.push(event),
|
||||
lifecycle: (event) => lifecycle.push(event),
|
||||
log: () => {},
|
||||
})
|
||||
|
||||
await wrapper.sync()
|
||||
expect(calls).toEqual(["status:/repo:true", "permission:/repo:true", "question:/repo:true"])
|
||||
expect(posted.at(-1)).toEqual(["/repo"])
|
||||
expect(conn.emit({ type: "session.created", properties: {} }, "/repo")).toBeUndefined()
|
||||
expect(conn.emit(status("s1", "idle"), "/repo")).toBeUndefined()
|
||||
expect(statuses).toHaveLength(1)
|
||||
expect(lifecycle).toHaveLength(1)
|
||||
expect(posted.at(-1)).toEqual([])
|
||||
|
||||
conn.change("disconnected")
|
||||
expect(posted.at(-1)).toEqual([])
|
||||
await wrapper.sync()
|
||||
expect(calls).toHaveLength(3)
|
||||
|
||||
conn.change("connected")
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toHaveLength(6)
|
||||
wrapper.dispose()
|
||||
conn.emit(status("s1", "busy"), "/repo")
|
||||
expect(statuses).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("replays before a forced sync checks connection state", async () => {
|
||||
const client = {
|
||||
session: { status: async () => ({ data: { s1: { type: "busy" } } }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
}
|
||||
const conn = connection(client)
|
||||
const posted: string[][] = []
|
||||
const wrapper = createWorktreeActivity({
|
||||
connection: conn as never,
|
||||
paths: () => ["/repo"],
|
||||
post: (active) => posted.push(active),
|
||||
status: () => {},
|
||||
lifecycle: () => {},
|
||||
log: () => {},
|
||||
})
|
||||
await wrapper.sync()
|
||||
conn.set("disconnected")
|
||||
posted.length = 0
|
||||
await wrapper.sync(true)
|
||||
expect(posted).toEqual([["/repo"]])
|
||||
wrapper.dispose()
|
||||
})
|
||||
})
|
||||
@@ -91,7 +91,7 @@ import { createProjectRegistry, type PersistedProjectTabs } from "./project/regi
|
||||
import type { WorktreeBusyState } from "./project/store"
|
||||
import { rememberTarget, restoreProjectTarget } from "./project/restore"
|
||||
import { createProjectStateRouter } from "./project/state"
|
||||
import { createSessionBusy } from "./project/session-busy"
|
||||
import { createWorktreeBusy } from "./project/session-busy"
|
||||
import { switchProject } from "./project/switch"
|
||||
import { createProjectStateHandlers } from "./project/state-handlers"
|
||||
import { ownsParent as ownsParentSession, isCurrent } from "./project/message-ownership"
|
||||
@@ -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,
|
||||
@@ -903,7 +903,7 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId)
|
||||
|
||||
const busy = createSessionBusy({
|
||||
const busy = createWorktreeBusy({
|
||||
statuses: session.allStatusMap,
|
||||
permissions: session.permissions,
|
||||
questions: session.questions,
|
||||
@@ -911,6 +911,8 @@ const AgentManagerContent: Component = () => {
|
||||
local: localSessionIDs,
|
||||
projects: projectSessionsLive,
|
||||
active: activeProjectId,
|
||||
worktrees: (id) => (id ? registry.ensure(id) : registry.active()).worktrees(),
|
||||
subscribe: vscode.onMessage,
|
||||
})
|
||||
const isAgentBusy = busy.agent
|
||||
const isLocalBusy = busy.local
|
||||
@@ -1389,6 +1391,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)
|
||||
@@ -1820,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()
|
||||
@@ -2319,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()}
|
||||
@@ -2428,9 +2431,7 @@ const AgentManagerContent: Component = () => {
|
||||
diffOpen={diffOpen}
|
||||
reviewActive={reviewActive}
|
||||
onToggleDiff={toggleDiffPanel}
|
||||
browserOpen={browser.visible}
|
||||
browserAutomation={browser.enabled}
|
||||
onToggleBrowser={browser.toggle}
|
||||
{...browser.tabs}
|
||||
onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)}
|
||||
prStatus={() => activePR()?.pr}
|
||||
prOpen={prOpen}
|
||||
|
||||
@@ -31,21 +31,19 @@ export function createBrowserPanel(
|
||||
review(false)
|
||||
panel(SidePanel.Browser)
|
||||
}
|
||||
const toggle = () => {
|
||||
if (!enabled()) return
|
||||
if (visible()) return close()
|
||||
open()
|
||||
}
|
||||
return {
|
||||
enabled,
|
||||
visible,
|
||||
close,
|
||||
tabs: { browserOpen: visible, browserAutomation: enabled, onToggleBrowser: toggle },
|
||||
bind: (current: Accessor<string | undefined>) => ({
|
||||
browser: configure,
|
||||
current,
|
||||
closeBrowser: close,
|
||||
openBrowser: open,
|
||||
}),
|
||||
toggle: () => {
|
||||
if (!enabled()) return
|
||||
if (visible()) return close()
|
||||
open()
|
||||
},
|
||||
render: (session: Accessor<string | undefined>, project: Accessor<string | undefined>) => (
|
||||
<Show when={enabled() && visible()}>
|
||||
<BrowserAdapter sessionId={session} projectId={project} onClose={close} />
|
||||
|
||||
@@ -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<string, string>
|
||||
t: LanguageContextValue["t"]
|
||||
@@ -217,7 +217,7 @@ export const ProjectList: Component<Props> = (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]}
|
||||
|
||||
@@ -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<string, WorktreeGitStats>
|
||||
local?: LocalGitStats
|
||||
@@ -81,6 +81,7 @@ export const ProjectSidebarBody: Component<Props> = (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> = (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}
|
||||
|
||||
@@ -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<string, number>
|
||||
worktreeStats: () => Record<string, WorktreeGitStats>
|
||||
@@ -315,6 +315,7 @@ export const SidebarBody: Component<SidebarBodyProps> = (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]}
|
||||
|
||||
@@ -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<WorktreeItemProps> = (props) => {
|
||||
{props.shortcut}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!props.busy && !props.pendingDelete}>
|
||||
<Show when={!props.busy && !props.working && !props.blocked && !props.pendingDelete}>
|
||||
<div
|
||||
class="am-worktree-close"
|
||||
onMouseEnter={() => setOverClose(true)}
|
||||
@@ -520,17 +521,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 && !props.blocked}>
|
||||
<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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import type { ExtensionMessage } from "../../src/types/messages"
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
worktreeId?: string | null
|
||||
@@ -9,6 +12,7 @@ interface Status {
|
||||
|
||||
interface Prompt {
|
||||
sessionID: string
|
||||
blocking?: boolean
|
||||
}
|
||||
|
||||
export function createSessionBusy(opts: {
|
||||
@@ -20,26 +24,62 @@ export function createSessionBusy(opts: {
|
||||
projects: () => Record<string, Item[]>
|
||||
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([...opts.permissions(), ...opts.questions()].map((item) => item.sessionID))
|
||||
const blocked = new Set(
|
||||
[...opts.permissions(), ...opts.questions().filter((item) => item.blocking !== false)].map(
|
||||
(item) => item.sessionID,
|
||||
),
|
||||
)
|
||||
return ids.some((id) => {
|
||||
const status = statuses[id]
|
||||
return !!status && status.type !== "idle" && !blocked.has(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]) }
|
||||
}
|
||||
|
||||
export function createWorktreeBusy(
|
||||
opts: Parameters<typeof createSessionBusy>[0] & {
|
||||
worktrees: (project?: string) => { id: string; path: string }[]
|
||||
subscribe: (callback: (message: ExtensionMessage) => void) => () => void
|
||||
},
|
||||
) {
|
||||
const busy = createSessionBusy(opts)
|
||||
const [active, setActive] = createSignal(new Set<string>())
|
||||
onCleanup(
|
||||
opts.subscribe((message) => {
|
||||
if (message.type === "agentManager.worktreeActivity") setActive(new Set(message.active))
|
||||
}),
|
||||
)
|
||||
const working = (id: string, project?: string) =>
|
||||
active().has(opts.worktrees(project).find((worktree) => worktree.id === id)?.path ?? "")
|
||||
return {
|
||||
...busy,
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +329,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" }
|
||||
@@ -1228,6 +1229,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(() => {
|
||||
@@ -1656,6 +1658,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" }
|
||||
@@ -1688,6 +1691,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
function handlePermissionRequest(permission: PermissionRequest) {
|
||||
if (removedSessions.has(permission.sessionID)) return
|
||||
setPermissions((prev) => upsertPermission(prev, permission))
|
||||
}
|
||||
|
||||
@@ -1719,6 +1723,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]
|
||||
@@ -1742,6 +1747,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]
|
||||
@@ -1960,6 +1966,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
function handleSessionDeleted(sessionID: string) {
|
||||
removedSessions.add(sessionID)
|
||||
pendingOptimistic.delete(sessionID)
|
||||
freshSessions.delete(sessionID)
|
||||
aborts.clear(sessionID)
|
||||
|
||||
@@ -133,6 +133,8 @@ export interface ErrorMessage {
|
||||
message: string
|
||||
code?: string
|
||||
sessionID?: string
|
||||
projectId?: string
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
export interface SendMessageFailedMessage {
|
||||
@@ -815,6 +817,11 @@ export interface AgentManagerSessionForkedMessage {
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
export interface AgentManagerWorktreeActivityMessage {
|
||||
type: "agentManager.worktreeActivity"
|
||||
active: string[]
|
||||
}
|
||||
|
||||
export interface AgentManagerSessionClosedMessage {
|
||||
type: "agentManager.sessionClosed"
|
||||
projectId?: string
|
||||
@@ -1564,6 +1571,7 @@ export type ExtensionMessage =
|
||||
| AgentManagerSessionAddedMessage
|
||||
| AgentManagerSessionForkedMessage
|
||||
| AgentManagerSessionClosedMessage
|
||||
| AgentManagerWorktreeActivityMessage
|
||||
| AgentManagerStateMessage
|
||||
| AgentManagerProjectsMessage
|
||||
| AgentManagerSelectionActivatedMessage
|
||||
|
||||
@@ -454,10 +454,7 @@ export namespace KiloSessions {
|
||||
// Same-title Updated (setTitle no-op / double session.renamed): still
|
||||
// consume a matching rename adoption after sync so the mark cannot
|
||||
// stick and swallow a later real local rename (Decision 8).
|
||||
const outcome = (():
|
||||
| { kind: "same" }
|
||||
| { kind: "adopted" }
|
||||
| { kind: "report"; generated: boolean } => {
|
||||
const outcome = ((): { kind: "same" } | { kind: "adopted" } | { kind: "report"; generated: boolean } => {
|
||||
if (sameTitle) return { kind: "same" }
|
||||
// Consume marks before the network hop so the 60s TTL does not span
|
||||
// token resolution + ingest.sync. Checks run even when prev is
|
||||
@@ -832,6 +829,25 @@ export namespace KiloSessions {
|
||||
])
|
||||
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(id)))
|
||||
},
|
||||
// kilocode_change - K1 W1 clone: import a cloud session in-process. The
|
||||
// dynamic import keeps the HTTP handler graph out of the remote-sender
|
||||
// module graph, mirroring the lazy cancelPrompt pattern.
|
||||
importFromCloud: async (cloneId) => {
|
||||
const [{ CloudSessionImportInProcess }, { AppRuntime }] = await Promise.all([
|
||||
import("@/kilocode/server/import-cloud-session-in-process"),
|
||||
import("@/effect/app-runtime"),
|
||||
])
|
||||
const { session, diffs, directory } = await AppRuntime.runPromise(
|
||||
CloudSessionImportInProcess.importSessionWithoutRestore(cloneId),
|
||||
)
|
||||
return {
|
||||
session,
|
||||
finalize: () =>
|
||||
AppRuntime.runPromise(
|
||||
CloudSessionImportInProcess.finalizeSessionImport({ sessionId: session.id, diffs, directory }),
|
||||
),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (seq !== remoteSeq) {
|
||||
|
||||
@@ -48,6 +48,11 @@ export namespace RemoteProtocol {
|
||||
export const Capabilities = z
|
||||
.object({
|
||||
attachments: z.boolean().optional(),
|
||||
// kilocode_change - sessionClone: present only when the CLI accepts a
|
||||
// cloud-session clone (create_session.cloneFromKiloSessionId). The old
|
||||
// wire form omits sessionClone; remove the mobile fail-closed check
|
||||
// when every shipped CLI advertises it.
|
||||
sessionClone: z.boolean().optional(),
|
||||
})
|
||||
.optional()
|
||||
export const Heartbeat = z.object({
|
||||
|
||||
@@ -61,6 +61,10 @@ const CreateSessionRequest = z
|
||||
agent: z.string().min(1).optional(),
|
||||
model: CreateSessionModel.optional(),
|
||||
orgId: z.string().uuid().optional(),
|
||||
// kilocode_change - cloneFromKiloSessionId: optional cloud-session import.
|
||||
// The old wire form omits this field and performs a fresh sessionCreate;
|
||||
// remove the fresh-create branch when every shipped CLI advertises sessionClone.
|
||||
cloneFromKiloSessionId: z.string().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -90,6 +94,20 @@ function errorName(error: unknown): string {
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change - create_session cloud-import error mapping. The import seam
|
||||
// rejects with a tagged error carrying the upstream `status` (or a
|
||||
// "CloudSessionImportUnauthorized" tag for missing credentials). Map those to
|
||||
// the exact wire literals; never surface the upstream message (it may embed
|
||||
// credentials) and never fall back to a fresh sessionCreate.
|
||||
function importErrorText(error: unknown): string {
|
||||
const value = error as { status?: unknown; _tag?: unknown } | null | undefined
|
||||
if (value?._tag === "CloudSessionImportUnauthorized") return "cloud session import unauthorized"
|
||||
if (value?.status === 404) return "cloud session not found"
|
||||
if (value?.status === 401) return "cloud session import unauthorized"
|
||||
if (value?.status === 403) return "cloud session import access denied"
|
||||
return "cloud session import failed"
|
||||
}
|
||||
|
||||
// kilocode_change start — lazy init to avoid circular dependency
|
||||
// (Server → RemoteRoutes → RemoteSender → SessionPrompt at module load time)
|
||||
type RemotePromptInput = Omit<SessionPrompt.PromptInput, "model"> & {
|
||||
@@ -173,6 +191,14 @@ export namespace RemoteSender {
|
||||
hasSession?: (sessionID: SessionID) => boolean
|
||||
ownedCount?: () => number
|
||||
cancelPrompt?: (sessionID: SessionID) => Promise<void>
|
||||
// kilocode_change - injectable cloud-session import seam for create_session
|
||||
// clone requests. Takes the cloud session id and returns the imported
|
||||
// local Session.Info plus a `finalize` closure that restores workspace
|
||||
// files and writes session_diff storage keys; the caller must run
|
||||
// `finalize` only after a successful attach. Production wires this to the
|
||||
// in-process import helper (dynamic import + AppRuntime.runPromise); a
|
||||
// missing seam is a wiring bug, never a fallback to sessionCreate.
|
||||
importFromCloud?: (cloneId: string) => Promise<{ session: Session.Info; finalize: () => Promise<void> }>
|
||||
catalog?: {
|
||||
readonly get: (sessionID: SessionID) => Promise<Session.Info>
|
||||
readonly messages: (sessionID: SessionID) => Promise<MessageV2.WithParts[]>
|
||||
@@ -799,6 +825,10 @@ export namespace RemoteSender {
|
||||
// (a) accepts an absent `sessionId` (instance-picker path), (b)
|
||||
// resolves the target directory from that session or options.directory,
|
||||
// (c) attaches in-process; attach failures roll back via sessionRemove.
|
||||
// kilocode_change - clone: an optional cloneFromKiloSessionId imports a
|
||||
// cloud session in-process (importFromCloud) instead of a fresh
|
||||
// sessionCreate; a missing importFromCloud seam is a wiring bug, never
|
||||
// a fallback to sessionCreate.
|
||||
const parsed = CreateSessionRequest.safeParse(msg.data)
|
||||
if (!parsed.success) {
|
||||
options.conn.send({
|
||||
@@ -817,6 +847,16 @@ export namespace RemoteSender {
|
||||
})
|
||||
return
|
||||
}
|
||||
const cloneId = parsed.data.cloneFromKiloSessionId
|
||||
const importFromCloud = options.importFromCloud
|
||||
if (cloneId && !importFromCloud) {
|
||||
options.conn.send({
|
||||
type: "response",
|
||||
id: msg.id,
|
||||
error: "invalid create_session command",
|
||||
})
|
||||
return
|
||||
}
|
||||
const createInput: CreateSessionInput = {
|
||||
...(parsed.data.agent ? { agent: parsed.data.agent } : {}),
|
||||
...(parsed.data.model
|
||||
@@ -842,6 +882,52 @@ export namespace RemoteSender {
|
||||
Option.map((p) => p.then((info) => info.directory)),
|
||||
Option.getOrElse(() => Promise.resolve(options.directory)),
|
||||
)
|
||||
if (cloneId) {
|
||||
// Clone path: import in-process, then attach. Import failures
|
||||
// map to the exact literals and never fall back to a fresh
|
||||
// sessionCreate; attach failures roll back the imported session.
|
||||
const outcome = await run({
|
||||
directory: targetDirectory,
|
||||
fn: async (): Promise<{ id: string } | { error: string }> => {
|
||||
let imported: { session: Session.Info; finalize: () => Promise<void> }
|
||||
try {
|
||||
imported = await importFromCloud!(cloneId)
|
||||
} catch (importError) {
|
||||
return { error: importErrorText(importError) }
|
||||
}
|
||||
try {
|
||||
await attachSession(imported.session.id)
|
||||
} catch (attachError) {
|
||||
// Roll back the imported root session so the DB does not
|
||||
// keep an orphan the relay never learned about. Swallow
|
||||
// the cleanup error; re-throw the original attach error.
|
||||
try {
|
||||
await sessionRemove(imported.session.id)
|
||||
} catch (cleanupError) {
|
||||
options.log.error("create session cleanup failed", {
|
||||
id: msg.id,
|
||||
error: errorName(cleanupError),
|
||||
})
|
||||
}
|
||||
throw attachError
|
||||
}
|
||||
// Restore workspace files and write storage keys only after
|
||||
// the attach succeeded. finalize never rejects.
|
||||
await imported.finalize()
|
||||
return { id: imported.session.id }
|
||||
},
|
||||
})
|
||||
if ("error" in outcome) {
|
||||
options.conn.send({ type: "response", id: msg.id, error: outcome.error })
|
||||
return
|
||||
}
|
||||
options.conn.send({
|
||||
type: "response",
|
||||
id: msg.id,
|
||||
result: { protocolVersion: 1, sessionID: outcome.id },
|
||||
})
|
||||
return
|
||||
}
|
||||
const result = await run({
|
||||
directory: targetDirectory,
|
||||
fn: async () => {
|
||||
|
||||
@@ -131,7 +131,12 @@ export namespace RemoteWS {
|
||||
let lastGood: SessionInfo[] | undefined
|
||||
let outstanding = 0
|
||||
let degradedCount = 0
|
||||
type Waiter = { resolve: () => void; reject: (err: unknown) => void; requireSessionId?: string; detachSessionId?: string }
|
||||
type Waiter = {
|
||||
resolve: () => void
|
||||
reject: (err: unknown) => void
|
||||
requireSessionId?: string
|
||||
detachSessionId?: string
|
||||
}
|
||||
let waiters: Waiter[] = []
|
||||
|
||||
function makeWaiter(): { promise: Promise<void>; waiter: Waiter } {
|
||||
@@ -151,7 +156,9 @@ export namespace RemoteWS {
|
||||
// One bounded gather. Never throws. Returns the fresh session list (and
|
||||
// optional instance advertisement), or undefined to signal a degraded
|
||||
// cycle (caller sends last known-good).
|
||||
async function gatherOnce(): Promise<{ sessions: SessionInfo[]; instance?: RemoteProtocol.Heartbeat["instance"] } | undefined> {
|
||||
async function gatherOnce(): Promise<
|
||||
{ sessions: SessionInfo[]; instance?: RemoteProtocol.Heartbeat["instance"] } | undefined
|
||||
> {
|
||||
if (outstanding >= maxOutstandingGathers) {
|
||||
degradedCount++
|
||||
options.log.warn("remote-ws heartbeat gather cap reached, degraded heartbeat", {
|
||||
@@ -183,7 +190,9 @@ export namespace RemoteWS {
|
||||
},
|
||||
)
|
||||
const outcome = await new Promise<
|
||||
{ kind: "ok"; sessions: SessionInfo[]; instance?: RemoteProtocol.Heartbeat["instance"] } | { kind: "err"; error: unknown } | { kind: "timeout" }
|
||||
| { kind: "ok"; sessions: SessionInfo[]; instance?: RemoteProtocol.Heartbeat["instance"] }
|
||||
| { kind: "err"; error: unknown }
|
||||
| { kind: "timeout" }
|
||||
>((resolve) => {
|
||||
let done = false
|
||||
const t = timers.setTimeout(() => {
|
||||
@@ -196,9 +205,7 @@ export namespace RemoteWS {
|
||||
done = true
|
||||
timers.clearTimeout(t)
|
||||
resolve(
|
||||
res.ok
|
||||
? { kind: "ok", sessions: res.sessions, instance: res.instance }
|
||||
: { kind: "err", error: res.error },
|
||||
res.ok ? { kind: "ok", sessions: res.sessions, instance: res.instance } : { kind: "err", error: res.error },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -261,7 +268,7 @@ export namespace RemoteWS {
|
||||
send({
|
||||
type: "heartbeat",
|
||||
protocolVersion: InstallationVersion,
|
||||
capabilities: { attachments: true },
|
||||
capabilities: { attachments: true, sessionClone: true },
|
||||
sessions: fresh.sessions,
|
||||
...(fresh.instance ? { instance: fresh.instance } : {}),
|
||||
})
|
||||
@@ -306,7 +313,7 @@ export namespace RemoteWS {
|
||||
send({
|
||||
type: "heartbeat",
|
||||
protocolVersion: InstallationVersion,
|
||||
capabilities: { attachments: true },
|
||||
capabilities: { attachments: true, sessionClone: true },
|
||||
sessions: lastGood ?? [],
|
||||
})
|
||||
waiters = cycleWaiters.concat(waiters)
|
||||
@@ -428,7 +435,11 @@ export namespace RemoteWS {
|
||||
return
|
||||
}
|
||||
const endpoint = `${options.url}/api/user/cli?token=${encodeURIComponent(token)}&connectionId=${connectionId}`
|
||||
options.log.info("remote-ws connecting", { connectionId, gen: g.id, endpoint: endpoint.replace(/token=[^&]+/, "token=***") })
|
||||
options.log.info("remote-ws connecting", {
|
||||
connectionId,
|
||||
gen: g.id,
|
||||
endpoint: endpoint.replace(/token=[^&]+/, "token=***"),
|
||||
})
|
||||
let socket: WebSocket
|
||||
try {
|
||||
socket = new WebSocket(endpoint)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import path from "node:path"
|
||||
import {
|
||||
GatewayError,
|
||||
SessionImportValidationError,
|
||||
fetchCloudSession,
|
||||
fetchCloudSessionForImport,
|
||||
fetchKiloImageModels,
|
||||
fetchKiloTranscriptionModels,
|
||||
getCloudSessions,
|
||||
getOrganizationId,
|
||||
getToken,
|
||||
normalizeClawStatus,
|
||||
prepareSessionImport,
|
||||
} from "@kilocode/kilo-gateway"
|
||||
import {
|
||||
HEADER_FEATURE,
|
||||
@@ -29,30 +25,22 @@ import { DIRECT_FIM_ENV, requestMistralFim, resolveFimTarget } from "@kilocode/k
|
||||
import { DIRECT_EDIT_ENV, extractFencedBody, resolveEditTarget } from "@kilocode/kilo-gateway/edit"
|
||||
import { buildMercuryEditPrompt } from "@kilocode/kilo-gateway/edit-prompt"
|
||||
import { buildKiloHeaders } from "@kilocode/kilo-gateway"
|
||||
import { Cause, Effect, Result, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { KilocodeConfig } from "@/kilocode/config/config"
|
||||
import { Auth } from "@/auth"
|
||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Instance } from "@/kilocode/instance"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ModelCache } from "@/provider/model-cache"
|
||||
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
|
||||
import { MessageTable, PartTable } from "@opencode-ai/core/session/sql"
|
||||
import { Session } from "@/session/session"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { AudioTranscriptionsBody, ClawStatus, CloudSessionImportError, EditBody, FimBody } from "../groups/kilo-gateway"
|
||||
import { baseKey } from "../../../session-portability/cumulative-diff"
|
||||
import { extractSessionDiffs, restoreSessionDiffs } from "../../../session-portability/session-diff-restore"
|
||||
|
||||
const FIM_TIMEOUT_MS = 30_000
|
||||
const log = Log.create({ service: "kilo-gateway" })
|
||||
@@ -458,138 +446,43 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
|
||||
})
|
||||
|
||||
const cloudSessionImport = Effect.fn("KiloGatewayHttpApi.cloudSessionImport")(function* (ctx) {
|
||||
const info = yield* auth.get("kilo").pipe(Effect.mapError(() => new HttpApiError.Unauthorized({})))
|
||||
const token = getToken(info)
|
||||
if (!token) return yield* Effect.fail(new HttpApiError.Unauthorized({}))
|
||||
|
||||
const fetched = yield* Effect.tryPromise({
|
||||
try: () => fetchCloudSessionForImport(token, ctx.payload.sessionId),
|
||||
catch: (err) => err,
|
||||
}).pipe(
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
logError("cloud/session/import", err)
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
// Load the helper lazily: a static top-level import pulls the HTTP
|
||||
// handler graph into the remote-sender module graph and breaks the
|
||||
// create_session test's module init. Run the helper's Effect on the
|
||||
// request Effect (yield*) so the request-scoped InstanceRef/WorkspaceRef
|
||||
// reach the persistence path instead of the AppRuntime default context.
|
||||
const { CloudSessionImportInProcess } = yield* Effect.promise(() =>
|
||||
import("@/kilocode/server/import-cloud-session-in-process"),
|
||||
)
|
||||
if (!fetched) return yield* Effect.fail(new CloudSessionImportError({ error: "Internal error" }))
|
||||
if (!fetched.ok) return jsonError(fetched.error, fetched.status)
|
||||
if (!fetched.data?.info?.id) return yield* Effect.fail(new HttpApiError.BadRequest({}))
|
||||
|
||||
const diffs = extractSessionDiffs(fetched.data)
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
const subdir = path.relative(path.resolve(Instance.worktree), Instance.directory).replaceAll("\\", "/")
|
||||
const prepared = yield* Effect.try({
|
||||
try: () => prepareSessionImport(fetched.data, { Instance, Identifier, workspaceID, path: subdir }),
|
||||
catch: (err) => {
|
||||
if (err instanceof SessionImportValidationError) return new HttpApiError.BadRequest({})
|
||||
const name =
|
||||
err instanceof Error
|
||||
? err.name
|
||||
: typeof err === "object" && err !== null && "_tag" in err && typeof err._tag === "string"
|
||||
? err._tag
|
||||
: "UnknownError"
|
||||
log.error("cloud session import failed", {
|
||||
route: "cloud/session/import",
|
||||
stage: "prepare",
|
||||
error: name,
|
||||
})
|
||||
return new CloudSessionImportError({ error: "Internal error" })
|
||||
},
|
||||
})
|
||||
const session = yield* Effect.try({
|
||||
try: () => Schema.decodeUnknownSync(Session.Info)(prepared.info),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
const messages = yield* Effect.try({
|
||||
try: () =>
|
||||
prepared.messages.map((row) => {
|
||||
const info = Schema.decodeUnknownSync(SessionV1.Info)(row.data)
|
||||
const { id, sessionID, ...data } = info
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- decoding validates the shape; the database type only removes readonly modifiers
|
||||
return { id, session_id: sessionID, time_created: row.time_created, data: data as DeepMutable<typeof data> }
|
||||
}),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
const parts = yield* Effect.try({
|
||||
try: () =>
|
||||
prepared.parts.map((row) => {
|
||||
const part = Schema.decodeUnknownSync(SessionV1.Part)(row.data)
|
||||
const { id, messageID, sessionID, ...data } = part
|
||||
return {
|
||||
id,
|
||||
message_id: messageID,
|
||||
session_id: sessionID,
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- decoding validates the shape; the database type only removes readonly modifiers
|
||||
data: data as DeepMutable<typeof data>,
|
||||
const outcome = yield* CloudSessionImportInProcess.importSession(ctx.payload.sessionId).pipe(
|
||||
Effect.provideService(Auth.Service, auth),
|
||||
Effect.provideService(EventV2Bridge.Service, events),
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(Storage.Service, storage),
|
||||
Effect.match({
|
||||
onFailure: (err) => {
|
||||
if (err instanceof CloudSessionImportInProcess.Unauthorized) return { tag: "unauthorized" as const }
|
||||
if (err instanceof CloudSessionImportInProcess.Upstream) {
|
||||
return { tag: "upstream" as const, error: err.error, status: err.status }
|
||||
}
|
||||
}),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
const imported = yield* Effect.gen(function* () {
|
||||
yield* events.publish(
|
||||
Session.Event.Created,
|
||||
{ sessionID: session.id, info: session },
|
||||
{
|
||||
commit: () =>
|
||||
Effect.gen(function* () {
|
||||
for (const row of messages) {
|
||||
yield* database.db.insert(MessageTable).values([row]).run().pipe(Effect.orDie)
|
||||
}
|
||||
for (const row of parts) {
|
||||
yield* database.db.insert(PartTable).values([row]).run().pipe(Effect.orDie)
|
||||
}
|
||||
}),
|
||||
if (err instanceof CloudSessionImportInProcess.BadRequest) return { tag: "badrequest" as const }
|
||||
return { tag: "internal" as const }
|
||||
},
|
||||
)
|
||||
return session
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
const err = Result.getOrUndefined(Cause.findDefect(cause)) ?? Result.getOrUndefined(Cause.findError(cause))
|
||||
const name =
|
||||
err instanceof Error
|
||||
? err.name
|
||||
: typeof err === "object" && err !== null && "_tag" in err && typeof err._tag === "string"
|
||||
? err._tag
|
||||
: "UnknownError"
|
||||
log.error("cloud session import failed", {
|
||||
route: "cloud/session/import",
|
||||
stage: "write",
|
||||
error: name,
|
||||
sessionID: session.id,
|
||||
messages: messages.length,
|
||||
parts: parts.length,
|
||||
})
|
||||
}).pipe(Effect.andThen(Effect.fail(new CloudSessionImportError({ error: "Internal error" })))),
|
||||
),
|
||||
onSuccess: (session) => ({ tag: "ok" as const, session }),
|
||||
}),
|
||||
)
|
||||
|
||||
if (diffs.length > 0) {
|
||||
yield* Effect.try({
|
||||
try: () => restoreSessionDiffs({ directory: Instance.directory, diffs }),
|
||||
catch: (err) => err,
|
||||
}).pipe(
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
logError("cloud/session/import/restore", err)
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.all([
|
||||
storage.write(baseKey(imported.id), diffs),
|
||||
storage.write(["session_diff", imported.id], diffs),
|
||||
]).pipe(
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
logError("cloud/session/import/diff", err)
|
||||
}),
|
||||
),
|
||||
)
|
||||
switch (outcome.tag) {
|
||||
case "unauthorized":
|
||||
return yield* Effect.fail(new HttpApiError.Unauthorized({}))
|
||||
case "upstream":
|
||||
return jsonError(outcome.error, outcome.status)
|
||||
case "badrequest":
|
||||
return yield* Effect.fail(new HttpApiError.BadRequest({}))
|
||||
case "internal":
|
||||
return yield* Effect.fail(new CloudSessionImportError({ error: "Internal error" }))
|
||||
case "ok":
|
||||
return outcome.session
|
||||
}
|
||||
|
||||
return imported
|
||||
})
|
||||
|
||||
const imageModels = Effect.fn("KiloGatewayHttpApi.imageModels")(function* () {
|
||||
|
||||
@@ -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,208 @@
|
||||
import path from "node:path"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { Auth } from "@/auth"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { MessageTable, PartTable } from "@opencode-ai/core/session/sql"
|
||||
import { Instance } from "@/kilocode/instance"
|
||||
import { Identifier } from "@/id/id"
|
||||
import {
|
||||
SessionImportValidationError,
|
||||
fetchCloudSessionForImport,
|
||||
getToken,
|
||||
prepareSessionImport,
|
||||
} from "@kilocode/kilo-gateway"
|
||||
import { baseKey } from "@/kilocode/session-portability/cumulative-diff"
|
||||
import { extractSessionDiffs, restoreSessionDiffs } from "@/kilocode/session-portability/session-diff-restore"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "import-cloud-session" })
|
||||
|
||||
/**
|
||||
* In-process cloud-session import. Shared by the HTTP `cloudSessionImport`
|
||||
* handler and the remote `create_session` clone path. Yields its own service
|
||||
* graph (never closing over kiloGatewayHandlers group variables) and fails
|
||||
* typed errors that the callers translate into their own wire shapes.
|
||||
*/
|
||||
export namespace CloudSessionImportInProcess {
|
||||
// Missing kilo credentials (auth absent or no token).
|
||||
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("CloudSessionImportUnauthorized", {}) {}
|
||||
|
||||
// The cloud fetch returned a non-ok status; carries the upstream status and
|
||||
// error string so callers can map 404/401/403 without logging tokens.
|
||||
export class Upstream extends Schema.TaggedErrorClass<Upstream>()("CloudSessionImportUpstream", {
|
||||
status: Schema.Number,
|
||||
error: Schema.String,
|
||||
}) {}
|
||||
|
||||
// The export failed validation or decoding before any persistence.
|
||||
export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()("CloudSessionImportBadRequest", {}) {}
|
||||
|
||||
// Any other failure (fetch threw, prepare threw, or the write failed).
|
||||
export class Internal extends Schema.TaggedErrorClass<Internal>()("CloudSessionImportInternal", {}) {}
|
||||
|
||||
function name(error: unknown): string {
|
||||
if (error instanceof Error) return error.name
|
||||
if (typeof error === "object" && error !== null && "_tag" in error && typeof error._tag === "string") {
|
||||
return error._tag
|
||||
}
|
||||
return "UnknownError"
|
||||
}
|
||||
|
||||
// Persist the imported session (events, messages, parts) and decode the
|
||||
// diffs, but do NOT touch the workspace or session_diff storage keys. Those
|
||||
// side effects are deferred to `finalizeSessionImport` so the clone path can
|
||||
// run them only after a successful attach.
|
||||
const importSessionCore = Effect.fn("CloudSessionImportInProcess.importSessionCore")(function* (sessionId: string) {
|
||||
const auth = yield* Auth.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const database = yield* Database.Service
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
|
||||
const info = yield* auth.get("kilo").pipe(Effect.mapError(() => new Unauthorized()))
|
||||
const token = getToken(info)
|
||||
if (!token) return yield* Effect.fail(new Unauthorized())
|
||||
|
||||
const fetched = yield* Effect.tryPromise({
|
||||
try: () => fetchCloudSessionForImport(token, sessionId),
|
||||
catch: (err) => {
|
||||
log.error("cloud session import failed", { route: "cloud/session/import", stage: "fetch", error: name(err) })
|
||||
return new Internal()
|
||||
},
|
||||
})
|
||||
if (!fetched.ok) return yield* Effect.fail(new Upstream({ status: fetched.status, error: fetched.error }))
|
||||
if (!fetched.data?.info?.id) return yield* Effect.fail(new BadRequest())
|
||||
|
||||
const diffs = extractSessionDiffs(fetched.data)
|
||||
const subdir = path.relative(path.resolve(Instance.worktree), Instance.directory).replaceAll("\\", "/")
|
||||
const prepared = yield* Effect.try({
|
||||
try: () => prepareSessionImport(fetched.data, { Instance, Identifier, workspaceID, path: subdir }),
|
||||
catch: (err) => {
|
||||
if (err instanceof SessionImportValidationError) return new BadRequest()
|
||||
log.error("cloud session import failed", {
|
||||
route: "cloud/session/import",
|
||||
stage: "prepare",
|
||||
error: name(err),
|
||||
})
|
||||
return new Internal()
|
||||
},
|
||||
})
|
||||
const session = yield* Effect.try({
|
||||
try: () => Schema.decodeUnknownSync(Session.Info)(prepared.info),
|
||||
catch: () => new BadRequest(),
|
||||
})
|
||||
const messages = yield* Effect.try({
|
||||
try: () =>
|
||||
prepared.messages.map((row) => {
|
||||
const info = Schema.decodeUnknownSync(SessionV1.Info)(row.data)
|
||||
const { id, sessionID, ...data } = info
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- decoding validates the shape; the database type only removes readonly modifiers
|
||||
return { id, session_id: sessionID, time_created: row.time_created, data: data as DeepMutable<typeof data> }
|
||||
}),
|
||||
catch: () => new BadRequest(),
|
||||
})
|
||||
const parts = yield* Effect.try({
|
||||
try: () =>
|
||||
prepared.parts.map((row) => {
|
||||
const part = Schema.decodeUnknownSync(SessionV1.Part)(row.data)
|
||||
const { id, messageID, sessionID, ...data } = part
|
||||
return {
|
||||
id,
|
||||
message_id: messageID,
|
||||
session_id: sessionID,
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- decoding validates the shape; the database type only removes readonly modifiers
|
||||
data: data as DeepMutable<typeof data>,
|
||||
}
|
||||
}),
|
||||
catch: () => new BadRequest(),
|
||||
})
|
||||
|
||||
const imported = yield* Effect.gen(function* () {
|
||||
yield* events.publish(
|
||||
Session.Event.Created,
|
||||
{ sessionID: session.id, info: session },
|
||||
{
|
||||
commit: () =>
|
||||
Effect.gen(function* () {
|
||||
for (const row of messages) {
|
||||
yield* database.db.insert(MessageTable).values([row]).run().pipe(Effect.orDie)
|
||||
}
|
||||
for (const row of parts) {
|
||||
yield* database.db.insert(PartTable).values([row]).run().pipe(Effect.orDie)
|
||||
}
|
||||
}),
|
||||
},
|
||||
)
|
||||
return session
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cloud session import failed", {
|
||||
route: "cloud/session/import",
|
||||
stage: "write",
|
||||
error: name(Cause.squash(cause)),
|
||||
sessionID: session.id,
|
||||
messages: messages.length,
|
||||
parts: parts.length,
|
||||
})
|
||||
return Effect.fail(new Internal())
|
||||
}),
|
||||
)
|
||||
|
||||
// The canonical Session.Info contract is the mutable DeepMutable type, not
|
||||
// the readonly Schema.decodeUnknownSync output. Cast so the remote
|
||||
// create_session clone seam (Promise<Session.Info>) and the HTTP handler
|
||||
// both receive the same shape.
|
||||
return { session: imported as DeepMutable<typeof imported>, diffs, directory: Instance.directory }
|
||||
})
|
||||
|
||||
// Workspace restore + session_diff storage writes, deferred to run after a
|
||||
// successful attach. Uses `Effect.catch` (Fail-only) so defects and fiber
|
||||
// interrupts propagate instead of being swallowed by `catchCause`.
|
||||
export const finalizeSessionImport = Effect.fn("CloudSessionImportInProcess.finalizeSessionImport")(
|
||||
function* (input: { sessionId: string; diffs: ReturnType<typeof extractSessionDiffs>; directory: string }) {
|
||||
if (input.diffs.length > 0) {
|
||||
yield* Effect.try({
|
||||
try: () => restoreSessionDiffs({ directory: input.directory, diffs: input.diffs }),
|
||||
catch: (err) => {
|
||||
log.error("cloud session import restore failed", {
|
||||
route: "cloud/session/import/restore",
|
||||
error: name(err),
|
||||
})
|
||||
return err
|
||||
},
|
||||
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const storage = yield* Storage.Service
|
||||
yield* Effect.all([
|
||||
storage.write(baseKey(input.sessionId), input.diffs),
|
||||
storage.write(["session_diff", input.sessionId], input.diffs),
|
||||
]).pipe(
|
||||
Effect.catch((err) => {
|
||||
log.error("cloud session import diff failed", {
|
||||
route: "cloud/session/import/diff",
|
||||
error: name(err),
|
||||
})
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const importSession = Effect.fn("CloudSessionImportInProcess.importSession")(function* (sessionId: string) {
|
||||
const { session, diffs, directory } = yield* importSessionCore(sessionId)
|
||||
yield* finalizeSessionImport({ sessionId: session.id, diffs, directory })
|
||||
return session
|
||||
})
|
||||
|
||||
// Persist only: no workspace restore and no session_diff storage writes.
|
||||
export const importSessionWithoutRestore = Effect.fn("CloudSessionImportInProcess.importSessionWithoutRestore")(
|
||||
function* (sessionId: string) {
|
||||
return yield* importSessionCore(sessionId)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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}`,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -3915,6 +3915,243 @@ describe("RemoteSender slash commands", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("create_session with cloneFromKiloSessionId imports in-process, attaches, and never creates", async () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const dirs: string[] = []
|
||||
const importCalls: string[] = []
|
||||
const createCalls: unknown[] = []
|
||||
const attachCalls: SessionID[] = []
|
||||
const order: string[] = []
|
||||
const importedId = SessionID.make("ses_imported")
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/process-default",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
provide: async <R>(input: { directory: string; fn: () => R }) => {
|
||||
dirs.push(input.directory)
|
||||
return input.fn()
|
||||
},
|
||||
session: {
|
||||
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
||||
children: async () => [],
|
||||
create: async (input) => {
|
||||
createCalls.push(input)
|
||||
return { id: SessionID.make("ses_fresh"), directory: "/workspace/project-a" } as any
|
||||
},
|
||||
},
|
||||
attachSession: async (input) => {
|
||||
attachCalls.push(input)
|
||||
order.push("attach")
|
||||
},
|
||||
importFromCloud: async (cloneId) => {
|
||||
importCalls.push(cloneId)
|
||||
return {
|
||||
session: { id: importedId, directory: "/workspace/project-a" } as any,
|
||||
finalize: async () => {
|
||||
order.push("finalize")
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const response = expectResponse(conn, sent, "req_clone")
|
||||
sender.handle({
|
||||
type: "command",
|
||||
id: "req_clone",
|
||||
command: "create_session",
|
||||
sessionId: "ses_current",
|
||||
data: { protocolVersion: 1, cloneFromKiloSessionId: "ses_cloud" },
|
||||
})
|
||||
await response.promise
|
||||
response.restore()
|
||||
|
||||
expect(dirs).toEqual(["/workspace/project-a"])
|
||||
expect(importCalls).toEqual(["ses_cloud"])
|
||||
expect(createCalls).toHaveLength(0)
|
||||
expect(attachCalls).toEqual([importedId])
|
||||
expect(order).toEqual(["attach", "finalize"])
|
||||
expect(sent).toEqual([{ type: "response", id: "req_clone", result: { protocolVersion: 1, sessionID: importedId } }])
|
||||
})
|
||||
|
||||
test("create_session with cloneFromKiloSessionId and no importFromCloud seam rejects without creating", () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const createCalls: unknown[] = []
|
||||
const attachCalls: SessionID[] = []
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/process-default",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
session: {
|
||||
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
||||
children: async () => [],
|
||||
create: async (input) => {
|
||||
createCalls.push(input)
|
||||
return { id: SessionID.make("ses_fresh"), directory: "/workspace/project-a" } as any
|
||||
},
|
||||
},
|
||||
attachSession: async (input) => {
|
||||
attachCalls.push(input)
|
||||
},
|
||||
// importFromCloud intentionally omitted — a missing seam must fail closed.
|
||||
})
|
||||
|
||||
sender.handle({
|
||||
type: "command",
|
||||
id: "req_clone_no_seam",
|
||||
command: "create_session",
|
||||
sessionId: "ses_current",
|
||||
data: { protocolVersion: 1, cloneFromKiloSessionId: "ses_cloud" },
|
||||
})
|
||||
|
||||
expect(sent).toEqual([{ type: "response", id: "req_clone_no_seam", error: "invalid create_session command" }])
|
||||
expect(createCalls).toHaveLength(0)
|
||||
expect(attachCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("create_session clone import failure maps each failure to its exact literal and never creates or attaches", async () => {
|
||||
const cases: Array<{ error: unknown; wire: string }> = [
|
||||
{ error: { status: 404, error: "Session not found in cloud" }, wire: "cloud session not found" },
|
||||
{ error: { status: 401, error: "Import failed: 401" }, wire: "cloud session import unauthorized" },
|
||||
{
|
||||
error: { _tag: "CloudSessionImportUnauthorized", message: "missing token" },
|
||||
wire: "cloud session import unauthorized",
|
||||
},
|
||||
{ error: { status: 403, error: "Import failed: 403" }, wire: "cloud session import access denied" },
|
||||
{ error: new Error("boom"), wire: "cloud session import failed" },
|
||||
]
|
||||
for (const { error, wire } of cases) {
|
||||
const { conn, sent } = fakeConn()
|
||||
const createCalls: unknown[] = []
|
||||
const attachCalls: SessionID[] = []
|
||||
const removeCalls: string[] = []
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/process-default",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
||||
session: {
|
||||
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
||||
children: async () => [],
|
||||
create: async (input) => {
|
||||
createCalls.push(input)
|
||||
return { id: SessionID.make("ses_fresh") } as any
|
||||
},
|
||||
remove: async (id) => {
|
||||
removeCalls.push(id)
|
||||
},
|
||||
},
|
||||
attachSession: async (input) => {
|
||||
attachCalls.push(input)
|
||||
},
|
||||
importFromCloud: async () => {
|
||||
throw error
|
||||
},
|
||||
})
|
||||
|
||||
const response = expectResponse(conn, sent, "req_clone_fail")
|
||||
sender.handle({
|
||||
type: "command",
|
||||
id: "req_clone_fail",
|
||||
command: "create_session",
|
||||
sessionId: "ses_current",
|
||||
data: { protocolVersion: 1, cloneFromKiloSessionId: "ses_cloud" },
|
||||
})
|
||||
await response.promise
|
||||
response.restore()
|
||||
|
||||
expect(createCalls).toHaveLength(0)
|
||||
expect(attachCalls).toHaveLength(0)
|
||||
expect(removeCalls).toHaveLength(0)
|
||||
expect(sent).toEqual([{ type: "response", id: "req_clone_fail", error: wire }])
|
||||
}
|
||||
})
|
||||
|
||||
test("create_session clone attach failure rolls back the imported session and never reports success", async () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const removeCalls: string[] = []
|
||||
const importedId = SessionID.make("ses_imported")
|
||||
const createCalls: unknown[] = []
|
||||
const finalizeCalls: string[] = []
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/process-default",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
||||
session: {
|
||||
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
||||
children: async () => [],
|
||||
create: async (input) => {
|
||||
createCalls.push(input)
|
||||
return { id: SessionID.make("ses_fresh") } as any
|
||||
},
|
||||
remove: async (id) => {
|
||||
removeCalls.push(id)
|
||||
},
|
||||
},
|
||||
attachSession: async () => {
|
||||
throw new Error("attach failed")
|
||||
},
|
||||
importFromCloud: async () => ({
|
||||
session: { id: importedId, directory: "/workspace/project-a" } as any,
|
||||
finalize: async () => {
|
||||
finalizeCalls.push("finalize")
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const response = expectResponse(conn, sent, "req_clone_attach_fail")
|
||||
sender.handle({
|
||||
type: "command",
|
||||
id: "req_clone_attach_fail",
|
||||
command: "create_session",
|
||||
sessionId: "ses_current",
|
||||
data: { protocolVersion: 1, cloneFromKiloSessionId: "ses_cloud" },
|
||||
})
|
||||
await response.promise
|
||||
response.restore()
|
||||
|
||||
expect(createCalls).toHaveLength(0)
|
||||
expect(removeCalls).toEqual([importedId])
|
||||
expect(finalizeCalls).toHaveLength(0)
|
||||
expect(sent).toEqual([{ type: "response", id: "req_clone_attach_fail", error: "failed to create session" }])
|
||||
})
|
||||
|
||||
test("create_session clone still rejects unknown fields under strict schema", () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const createCalls: unknown[] = []
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
session: {
|
||||
get: async (sessionID) => ({ id: sessionID, directory: "/tmp" }) as any,
|
||||
children: async () => [],
|
||||
create: async (input) => {
|
||||
createCalls.push(input)
|
||||
return { id: SessionID.make("ses_x"), directory: "/tmp" } as any
|
||||
},
|
||||
},
|
||||
attachSession: async () => {},
|
||||
importFromCloud: async () => ({
|
||||
session: { id: SessionID.make("ses_imported"), directory: "/tmp" } as any,
|
||||
finalize: async () => {},
|
||||
}),
|
||||
})
|
||||
sender.handle({
|
||||
type: "command",
|
||||
id: "req_clone_strict",
|
||||
command: "create_session",
|
||||
data: { protocolVersion: 1, cloneFromKiloSessionId: "ses_cloud", unknown: true },
|
||||
})
|
||||
expect(sent).toEqual([{ type: "response", id: "req_clone_strict", error: "invalid create_session command" }])
|
||||
expect(createCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("system session.renamed applies setTitle and marks adoption", async () => {
|
||||
const { conn } = fakeConn()
|
||||
const titles: { sessionID: string; title: string }[] = []
|
||||
|
||||
@@ -252,7 +252,7 @@ describe("RemoteWS", () => {
|
||||
await settled()
|
||||
const raw = await msg
|
||||
const parsed = JSON.parse(raw)
|
||||
expect(parsed.capabilities).toEqual({ attachments: true })
|
||||
expect(parsed.capabilities).toEqual({ attachments: true, sessionClone: true })
|
||||
})
|
||||
|
||||
test("serializes concurrent heartbeat snapshots", async () => {
|
||||
|
||||
@@ -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<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("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<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")
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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<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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user