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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cloud-session-diff-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---

Restore cloud session filesystem changes from synced session diffs when importing sessions, including inherited changes across imported session forks.
16 changes: 14 additions & 2 deletions packages/opencode/src/kilo-sessions/kilo-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { Telemetry } from "@kilocode/kilo-telemetry"
import { Question } from "@/question"
import { Permission } from "@/permission"
import { withTimeout } from "@/util/timeout"
import { Snapshot } from "@/snapshot"
import { cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff"

async function provide<R>(input: { directory: string; fn: () => R }): Promise<R> {
const { WithInstance } = await import("@/project/with-instance")
Expand Down Expand Up @@ -225,6 +227,13 @@ export namespace KiloSessions {
await ingest.sync(sessionID, [{ type: "session_status", data: { status } }])
}

async function cumulative(sessionId: string, local: Snapshot.FileDiff[]) {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(
Storage.Service.use((storage) => cumulativeSessionDiff(storage, SessionID.make(sessionId), local)),
)
}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
Expand Down Expand Up @@ -273,7 +282,9 @@ export namespace KiloSessions {
ingest.sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]),
)
yield* watch(Session.Event.Diff, (evt) =>
ingest.sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]),
cumulative(evt.properties.sessionID, evt.properties.diff).then((diff) =>
ingest.sync(evt.properties.sessionID, [{ type: "session_diff", data: diff }]),
),
)
yield* watch(Session.Event.TurnOpen, (evt) =>
ingest.sync(evt.properties.sessionID, [{ type: "session_open", data: {} }]),
Expand Down Expand Up @@ -673,7 +684,7 @@ export namespace KiloSessions {
log.info("full sync", { sessionId })

const { AppRuntime } = await import("@/effect/app-runtime")
const [session, diffs] = await AppRuntime.runPromise(
const [session, local] = await AppRuntime.runPromise(
Effect.gen(function* () {
const sessions = yield* Session.Service
const summary = yield* SessionSummary.Service
Expand All @@ -683,6 +694,7 @@ export namespace KiloSessions {
])
}),
)
const diffs = await cumulative(sessionId, local)
const messages = await Array.fromAsync(MessageV2.stream(SessionID.make(sessionId)))
messages.reverse()
const mdls = await models(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { MessageTable, PartTable, SessionTable } from "@/session/session.sql"
import { Session } from "@/session/session"
import { Database } from "@/storage/db"
import { Storage } from "@/storage/storage"
import { AudioTranscriptionsBody, ClawStatus, 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" })
Expand Down Expand Up @@ -440,23 +443,57 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
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 bridge = yield* EffectBridge.make()
return yield* Effect.tryPromise({
try: () =>
bridge.promise(
Effect.sync(() =>
importSessionToDb(fetched.data, {
Database,
Instance,
SessionTable,
MessageTable,
PartTable,
SessionToRow: Session.toRow,
Bus,
SessionCreatedEvent: Session.Event.Created,
Identifier,
}),
),
Effect.gen(function* () {
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)
return undefined
}),
),
)
}

const imported = yield* Effect.sync(() =>
importSessionToDb(fetched.data, {
Database,
Instance,
SessionTable,
MessageTable,
PartTable,
SessionToRow: Session.toRow,
Bus,
SessionCreatedEvent: Session.Event.Created,
Identifier,
}),
)

if (diffs.length > 0) {
yield* Storage.Service.use((storage) =>
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)
}),
),
)
}

return imported
}),
),
catch: () => new HttpApiError.BadRequest({}),
})
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/kilocode/session-export/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ scope.onmessage = (event) => {
endpoint: resolveEndpoint({
endpoint: msg.endpoint,
env: process.env.KILO_SESSION_EXPORT_INGEST,
allowCustom: process.env.KILO_SESSION_EXPORT_ALLOW_CUSTOM_INGEST === "1",
allowCustom: msg.allowCustomEndpoint || process.env.KILO_SESSION_EXPORT_ALLOW_CUSTOM_INGEST === "1",
}),
fetch: globalThis.fetch,
reportTelemetry: (item) => scope.postMessage(item),
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/kilocode/session-export/worker/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import type { ExportEvent } from "../events"

export type ToWorker =
| { kind: "init"; dbPath: string; agentVersion?: string; endpoint?: string; surface?: string; anonId?: string }
| {
kind: "init"
dbPath: string
agentVersion?: string
endpoint?: string
allowCustomEndpoint?: boolean
surface?: string
anonId?: string
}
| { kind: "event"; envelope: ExportEvent; approxBytes: number }
| { kind: "shutdown"; timeoutMs: number }
| { kind: "network_reconnect" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function parseMessage(value: unknown): ToWorker | undefined {
dbPath: value.dbPath,
agentVersion: text(value.agentVersion),
endpoint: text(value.endpoint),
allowCustomEndpoint: value.allowCustomEndpoint === true,
surface: text(value.surface),
anonId: text(value.anonId),
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Effect } from "effect"
import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage"
import type { SessionID } from "@/session/schema"

export type PortableDiff = Snapshot.FileDiff & {
after?: string
}

export const baseKey = (id: SessionID | string) => ["session_diff_base", String(id)]

function equal(left: unknown, right: unknown) {
return JSON.stringify(left) === JSON.stringify(right)
}

function starts(base: PortableDiff[], local: PortableDiff[]) {
if (local.length < base.length) return false
return base.every((diff, index) => equal(diff, local[index]))
}

function ends(base: PortableDiff[], local: PortableDiff[]) {
if (base.length < local.length) return false
const start = base.length - local.length
return local.every((diff, index) => equal(diff, base[start + index]))
}

export function mergeSessionDiffs(input: { base: PortableDiff[]; local: PortableDiff[] }) {
if (input.base.length === 0) return input.local
if (input.local.length === 0) return input.base
if (starts(input.base, input.local)) return input.local
return [...input.base, ...input.local]
}

export function appendSessionDiffs(input: { existing: PortableDiff[]; next: PortableDiff[] }) {
if (input.existing.length === 0) return input.next
if (input.next.length === 0) return input.existing
if (starts(input.existing, input.next)) return input.next
if (starts(input.next, input.existing)) return input.existing
if (ends(input.existing, input.next)) return input.existing
return [...input.existing, ...input.next]
Comment thread
iscekic marked this conversation as resolved.
}

export function readSessionDiffBase(storage: Storage.Interface, id: SessionID | string) {
return storage.read<PortableDiff[]>(baseKey(id)).pipe(Effect.catch(() => Effect.succeed([] as PortableDiff[])))
}

export function cumulativeSessionDiff(storage: Storage.Interface, id: SessionID | string, local: PortableDiff[]) {
return readSessionDiffBase(storage, id).pipe(Effect.map((base) => mergeSessionDiffs({ base, local })))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"

type Diff = {
file?: string
patch?: string
after?: string
additions?: number
deletions?: number
status?: string
}

export type RestoreResult = {
applied: number
skipped: number
total: number
}

function diffs(value: unknown): Diff[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is Diff => typeof item === "object" && item !== null)
}

export function extractSessionDiffs(data: unknown): Diff[] {
if (typeof data !== "object" || data === null) return []
const root = data as { sessionDiff?: unknown; session_diff?: unknown; messages?: unknown }
const top = diffs(root.sessionDiff).length > 0 ? diffs(root.sessionDiff) : diffs(root.session_diff)
if (top.length > 0) return top
if (!Array.isArray(root.messages)) return []

const map = new Map<string, Diff>()
for (const msg of root.messages) {
if (typeof msg !== "object" || msg === null) continue
const info = (msg as { info?: unknown }).info
if (typeof info !== "object" || info === null) continue
const summary = (info as { summary?: unknown }).summary
if (typeof summary !== "object" || summary === null) continue
for (const diff of diffs((summary as { diffs?: unknown }).diffs)) {
if (typeof diff.file === "string") map.set(diff.file, diff)
}
}
return Array.from(map.values())
}

function safe(root: string, file: string) {
const fp = path.resolve(root, file)
const rel = path.relative(root, fp)
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return
return fp
}

function apply(dir: string, diff: Diff) {
if (!diff.patch) return false
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-session-diff-"))
const file = path.join(tmp, "change.patch")
try {
const text = diff.patch.endsWith("\n") ? diff.patch : diff.patch + "\n"
fs.writeFileSync(file, text)
const proc = Bun.spawnSync(["git", "apply", "--3way", "--whitespace=nowarn", file], {
Comment thread
iscekic marked this conversation as resolved.
cwd: dir,
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
})
return proc.exitCode === 0
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
}

export function restoreSessionDiffs(input: { directory: string; diffs: Diff[] }): RestoreResult {
Comment thread
iscekic marked this conversation as resolved.
const root = path.resolve(input.directory)
const total = input.diffs.length
const result = { applied: 0, skipped: 0, total }

for (const diff of input.diffs) {
if (diff.patch) {
if (apply(root, diff)) {
result.applied++
continue
}
result.skipped++
continue
}

if (typeof diff.file !== "string") {
result.skipped++
continue
}
const fp = safe(root, diff.file)
if (!fp) {
result.skipped++
continue
}

if (diff.status === "deleted") {
fs.rmSync(fp, { force: true })
result.applied++
continue
}

if (typeof diff.after !== "string" || diff.after.length === 0) {
result.skipped++
continue
}
fs.mkdirSync(path.dirname(fp), { recursive: true })
fs.writeFileSync(fp, diff.after)
result.applied++
}

return result
}
15 changes: 11 additions & 4 deletions packages/opencode/src/kilocode/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import * as Truncate from "@/tool/truncate"

const log = Log.create({ service: "kilocode-tool-registry" })
type Deps = { agent: Agent.Interface; truncate: Truncate.Interface }
type Loaders = {
indexing?: () => Promise<{ KiloIndexing: { ready: () => boolean } }>
semantic?: () => Promise<Pick<typeof import("@/kilocode/tool/semantic-search"), "SemanticSearchTool">>
}

export namespace KiloToolRegistry {
const hint =
Expand All @@ -34,6 +38,7 @@ export namespace KiloToolRegistry {
export function build(
tools: { codebase: Tool.Info; recall: Tool.Info; manager: Tool.Info; process: Tool.Info },
deps: Deps,
loaders: Loaders = {},
) {
return Effect.gen(function* () {
const base = yield* Effect.all({
Expand All @@ -42,15 +47,16 @@ export namespace KiloToolRegistry {
manager: Tool.init(tools.manager),
process: Tool.init(tools.process),
})
const semantic = yield* semanticTool(deps)
const semantic = yield* semanticTool(deps, loaders)
return { ...base, semantic }
})
}

function semanticTool(deps: Deps) {
function semanticTool(deps: Deps, loaders: Loaders) {
return Effect.gen(function* () {
const indexing = loaders.indexing ?? (() => import("@/kilocode/indexing"))
const ready = yield* Effect.tryPromise(() =>
import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.ready()),
indexing().then((mod) => mod.KiloIndexing.ready()),
).pipe(
Effect.catch((err) =>
Effect.sync(() => {
Expand All @@ -61,7 +67,8 @@ export namespace KiloToolRegistry {
)
if (!ready) return undefined

const mod = yield* Effect.tryPromise(() => import("@/kilocode/tool/semantic-search")).pipe(
const semantic = loaders.semantic ?? (() => import("@/kilocode/tool/semantic-search"))
const mod = yield* Effect.tryPromise(() => semantic()).pipe(
Effect.catch((err) =>
Effect.sync(() => {
log.warn("semantic search tool unavailable", { err })
Expand Down
Loading
Loading