feat(cli): import cloud transcript on create_session (#13483)

* feat(cli): import cloud transcript on create_session

* fix(cli): satisfy typecheck for in-process cloud import

Provide the helper's effect services from the group context so the
HTTP import handler keeps the base request graph. Map the tagged
helper errors via Effect.match instead of matchEffect.

* refactor(server): remove dead cloud-import error fields

* fix(cli): restore clone workspace files only after attach
This commit is contained in:
Igor Šćekić
2026-08-27 14:33:32 +02:00
committed by GitHub
parent 1e6131b0d7
commit 156fb64fdb
8 changed files with 611 additions and 155 deletions
@@ -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)
@@ -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* () {
@@ -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)
},
)
}
@@ -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 () => {