|
1 | | -import { resolve } from 'node:path'; |
2 | | -import { z } from 'zod'; |
3 | 1 | import type { |
4 | 2 | ExecutionSession, |
5 | 3 | SandboxInstance, |
@@ -933,185 +931,6 @@ export class SessionService { |
933 | 931 | }; |
934 | 932 | } |
935 | 933 |
|
936 | | - /** |
937 | | - * Write a snapshot payload to a temp file, run `kilo import`, then clean up. |
938 | | - * |
939 | | - * @param snapshotPayload - Pre-fetched JSON string of the session snapshot. |
940 | | - */ |
941 | | - private async restoreSessionSnapshot( |
942 | | - session: ExecutionSession, |
943 | | - sessionId: string, |
944 | | - userId: string, |
945 | | - snapshotPayload: string |
946 | | - ): Promise<void> { |
947 | | - const tmpPath = `/tmp/kilo-session-export-${sessionId}.json`; |
948 | | - let wroteSnapshot = false; |
949 | | - try { |
950 | | - await session.writeFile(tmpPath, snapshotPayload); |
951 | | - wroteSnapshot = true; |
952 | | - |
953 | | - const importResult = await session.exec(`kilo import "${tmpPath}"`); |
954 | | - if (importResult.exitCode !== 0) { |
955 | | - logger |
956 | | - .withFields({ |
957 | | - sessionId, |
958 | | - userId, |
959 | | - exitCode: importResult.exitCode, |
960 | | - stderr: importResult.stderr, |
961 | | - stdout: importResult.stdout, |
962 | | - }) |
963 | | - .error('Session snapshot import failed'); |
964 | | - throw new Error(`Session snapshot import failed with exit code ${importResult.exitCode}`); |
965 | | - } |
966 | | - } catch (error) { |
967 | | - logger |
968 | | - .withFields({ |
969 | | - sessionId, |
970 | | - userId, |
971 | | - error: error instanceof Error ? error.message : String(error), |
972 | | - }) |
973 | | - .error('Session snapshot restore failed'); |
974 | | - throw error instanceof Error ? error : new Error(String(error)); |
975 | | - } finally { |
976 | | - if (wroteSnapshot) { |
977 | | - try { |
978 | | - await session.deleteFile(tmpPath); |
979 | | - } catch (error) { |
980 | | - logger |
981 | | - .withFields({ |
982 | | - sessionId, |
983 | | - userId, |
984 | | - error: error instanceof Error ? error.message : String(error), |
985 | | - }) |
986 | | - .debug('Failed to delete session snapshot temp file'); |
987 | | - } |
988 | | - } |
989 | | - } |
990 | | - } |
991 | | - |
992 | | - /** |
993 | | - * Apply file-level changes from a pre-parsed diff array on top of the freshly |
994 | | - * cloned repo. Called during cold-start resume after `restoreSessionSnapshot`. |
995 | | - * |
996 | | - * Each diff entry contains the full `after` content, so we simply write (or |
997 | | - * delete) files to recreate the workspace state from the previous session. |
998 | | - * |
999 | | - * @param diffs - Pre-parsed `FileDiff[]` array (or `null` when no diff exists). |
1000 | | - */ |
1001 | | - private async applySessionDiff( |
1002 | | - session: ExecutionSession, |
1003 | | - sessionId: string, |
1004 | | - userId: string, |
1005 | | - workspacePath: string, |
1006 | | - diffs: Array<{ |
1007 | | - file: string; |
1008 | | - after: string; |
1009 | | - status?: 'added' | 'deleted' | 'modified'; |
1010 | | - }> | null |
1011 | | - ): Promise<void> { |
1012 | | - if (!Array.isArray(diffs) || diffs.length === 0) { |
1013 | | - return; |
1014 | | - } |
1015 | | - |
1016 | | - let applied = 0; |
1017 | | - let skipped = 0; |
1018 | | - |
1019 | | - for (const diff of diffs) { |
1020 | | - if (!diff.file) { |
1021 | | - skipped++; |
1022 | | - continue; |
1023 | | - } |
1024 | | - |
1025 | | - // Skip binary files — they have empty after content |
1026 | | - if (diff.status !== 'deleted' && !diff.after) { |
1027 | | - skipped++; |
1028 | | - continue; |
1029 | | - } |
1030 | | - |
1031 | | - const filePath = resolve(workspacePath, diff.file); |
1032 | | - if (!filePath.startsWith(workspacePath + '/')) { |
1033 | | - logger |
1034 | | - .withFields({ sessionId, userId, file: diff.file }) |
1035 | | - .warn('Skipping diff entry with path outside workspace'); |
1036 | | - skipped++; |
1037 | | - continue; |
1038 | | - } |
1039 | | - |
1040 | | - try { |
1041 | | - if (diff.status === 'deleted') { |
1042 | | - await session.deleteFile(filePath); |
1043 | | - applied++; |
1044 | | - } else { |
1045 | | - // Ensure parent directory exists. |
1046 | | - // Use single-quoted path to prevent shell metacharacter injection. |
1047 | | - const lastSlash = filePath.lastIndexOf('/'); |
1048 | | - if (lastSlash > 0) { |
1049 | | - const parentDir = filePath.substring(0, lastSlash); |
1050 | | - const escaped = parentDir.replaceAll("'", "'\\''"); |
1051 | | - await session.exec(`mkdir -p '${escaped}'`); |
1052 | | - } |
1053 | | - await session.writeFile(filePath, diff.after); |
1054 | | - applied++; |
1055 | | - } |
1056 | | - } catch (error) { |
1057 | | - logger |
1058 | | - .withFields({ |
1059 | | - sessionId, |
1060 | | - userId, |
1061 | | - file: diff.file, |
1062 | | - status: diff.status, |
1063 | | - error: error instanceof Error ? error.message : String(error), |
1064 | | - }) |
1065 | | - .warn('Failed to apply file diff (non-fatal, continuing)'); |
1066 | | - skipped++; |
1067 | | - } |
1068 | | - } |
1069 | | - |
1070 | | - logger |
1071 | | - .withFields({ sessionId, userId, applied, skipped, total: diffs.length }) |
1072 | | - .info('Applied session diff'); |
1073 | | - } |
1074 | | - |
1075 | | - /** |
1076 | | - * Extract last-write-wins file diffs from streamed snapshot messages. |
1077 | | - * The snapshot format is `{ info, messages: [{ info: { summary: { diffs } }, parts }] }`. |
1078 | | - * Returns null when no diffs exist. |
1079 | | - */ |
1080 | | - private static extractDiffsFromMessages( |
1081 | | - payload: string |
1082 | | - ): Array<{ file: string; after: string; status?: 'added' | 'deleted' | 'modified' }> | null { |
1083 | | - const fileDiffSchema = z.object({ |
1084 | | - file: z.string(), |
1085 | | - after: z.string().default(''), |
1086 | | - status: z.enum(['added', 'deleted', 'modified']).default('modified'), |
1087 | | - }); |
1088 | | - |
1089 | | - try { |
1090 | | - const parsed = JSON.parse(payload) as { |
1091 | | - messages?: Array<{ info?: { summary?: { diffs?: unknown[] } } }>; |
1092 | | - }; |
1093 | | - if (!parsed.messages) return null; |
1094 | | - |
1095 | | - const byFile = new Map<string, z.infer<typeof fileDiffSchema>>(); |
1096 | | - |
1097 | | - for (const msg of parsed.messages) { |
1098 | | - const diffs = msg.info?.summary?.diffs; |
1099 | | - if (!Array.isArray(diffs)) continue; |
1100 | | - |
1101 | | - for (const d of diffs) { |
1102 | | - const result = fileDiffSchema.safeParse(d); |
1103 | | - if (!result.success) continue; |
1104 | | - byFile.set(result.data.file, result.data); |
1105 | | - } |
1106 | | - } |
1107 | | - |
1108 | | - if (byFile.size === 0) return null; |
1109 | | - return [...byFile.values()]; |
1110 | | - } catch { |
1111 | | - return null; |
1112 | | - } |
1113 | | - } |
1114 | | - |
1115 | 934 | /** |
1116 | 935 | * Initialize a cloud-agent session by resuming an existing kilo session. |
1117 | 936 | * |
@@ -1475,38 +1294,62 @@ export class SessionService { |
1475 | 1294 | await writeAuthFile(sandbox, context.sessionHome, kilocodeToken); |
1476 | 1295 | await writeGlobalRules(sandbox, context.sessionHome, sessionId); |
1477 | 1296 |
|
1478 | | - // Fetch snapshot from session-ingest DO and buffer it for sandbox writeFile (string-only API). |
1479 | | - const internalSecret = await env.INTERNAL_API_SECRET_PROD.get(); |
1480 | | - const response = await env.SESSION_INGEST.fetch( |
1481 | | - new Request(`https://session-ingest/internal/session/${metadata.kiloSessionId}/export`, { |
1482 | | - headers: { |
1483 | | - 'X-Internal-Secret': internalSecret, |
1484 | | - 'X-Kilo-User-Id': userId, |
1485 | | - }, |
1486 | | - }) |
| 1297 | + // Single restore script handles download, import, and diff application inside |
| 1298 | + // the sandbox — the snapshot never enters worker memory. |
| 1299 | + logger.info('Starting cold-start session restore'); |
| 1300 | + |
| 1301 | + const escapedId = metadata.kiloSessionId.replaceAll("'", "'\\''"); |
| 1302 | + const escapedWorkspace = context.workspacePath.replaceAll("'", "'\\''"); |
| 1303 | + const restoreResult = await session.exec( |
| 1304 | + `bun /usr/local/bin/kilo-restore-session.js '${escapedId}' '${escapedWorkspace}'` |
1487 | 1305 | ); |
1488 | 1306 |
|
1489 | | - if (response.status === 404) { |
| 1307 | + if (restoreResult.exitCode !== 0) { |
| 1308 | + logger |
| 1309 | + .withFields({ |
| 1310 | + sessionId, |
| 1311 | + userId, |
| 1312 | + exitCode: restoreResult.exitCode, |
| 1313 | + stderr: restoreResult.stderr, |
| 1314 | + stdout: restoreResult.stdout, |
| 1315 | + }) |
| 1316 | + .error('Cold-start session restore failed'); |
| 1317 | + |
| 1318 | + // Parse stdout JSON for structured error info |
| 1319 | + let code: number | undefined; |
| 1320 | + try { |
| 1321 | + const parsed = JSON.parse(restoreResult.stdout?.trim() ?? '{}') as Record< |
| 1322 | + string, |
| 1323 | + unknown |
| 1324 | + >; |
| 1325 | + if (typeof parsed.code === 'number') { |
| 1326 | + code = parsed.code; |
| 1327 | + } |
| 1328 | + } catch { |
| 1329 | + // non-JSON stdout, ignore |
| 1330 | + } |
| 1331 | + |
| 1332 | + if (code === 404) { |
| 1333 | + throw new SessionSnapshotRestoreError( |
| 1334 | + 'Session snapshot restore failed: session not found', |
| 1335 | + 404 |
| 1336 | + ); |
| 1337 | + } |
1490 | 1338 | throw new SessionSnapshotRestoreError( |
1491 | | - `Session snapshot restore failed: session not found`, |
1492 | | - 404 |
| 1339 | + `Cold-start session restore failed: exit ${restoreResult.exitCode}`, |
| 1340 | + code |
1493 | 1341 | ); |
1494 | 1342 | } |
1495 | | - if (!response.ok) { |
1496 | | - throw new Error(`Session export failed: ${response.status}`); |
1497 | | - } |
1498 | 1343 |
|
1499 | | - const snapshotPayload = await response.text(); |
1500 | | - |
1501 | | - // Extract diffs client-side from the streamed snapshot messages. |
1502 | | - const diffs = SessionService.extractDiffsFromMessages(snapshotPayload); |
1503 | | - |
1504 | | - await this.restoreSessionSnapshot(session, sessionId, userId, snapshotPayload); |
1505 | | - |
1506 | | - // Apply file-level changes from the previous session on top of the fresh clone. |
1507 | | - // This runs after kilo import (conversation restore) since the CLI doesn't need |
1508 | | - // the file state during import — it only restores its internal session DB. |
1509 | | - await this.applySessionDiff(session, sessionId, userId, context.workspacePath, diffs); |
| 1344 | + // Log structured summary from restore script |
| 1345 | + try { |
| 1346 | + const summary = JSON.parse(restoreResult.stdout?.trim() ?? '{}') as Record<string, unknown>; |
| 1347 | + logger |
| 1348 | + .withFields({ sessionId, userId, ...summary }) |
| 1349 | + .info('Cold-start session restore completed'); |
| 1350 | + } catch { |
| 1351 | + // non-JSON stdout, non-fatal |
| 1352 | + } |
1510 | 1353 |
|
1511 | 1354 | // Re-run setup commands (fresh clone, need to reinstall) |
1512 | 1355 | if (metadata.setupCommands && metadata.setupCommands.length > 0) { |
|
0 commit comments