File size: 6,641 Bytes
5710dd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
// apps/vis/server/src/lib/import-store.ts
//
// Imported debug bundles (`/export-debug-zip` zips) live under
// `<home>/imported/<importId>/`, unzipped to the same on-disk shape as a real
// session directory (state.json, agents/<id>/wire.jsonl, tasks/, cron/, logs/)
// plus the bundle's `manifest.json`. Because the layout matches a session
// directory, every existing read path (wire / context / tasks / cron / blobs /
// logs) works against an imported session once `session-store` resolves its
// directory — see `findSessionDir`.
//
// This module owns ONLY: id allocation, safe extraction, validation, the
// vis-side `import-meta.json` sidecar, enumeration, and deletion. Summary /
// detail construction stays in `session-store` so imported and local sessions
// share one code path.

import { randomBytes } from 'node:crypto';
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import type { ImportInfo, ImportManifest } from './agent-record-types';
import { extractZip, ZipImportError } from './zip-import';

const IMPORT_ID_RE = /^imp_[0-9a-f]{12}$/;
const META_FILE = 'import-meta.json';

export function isImportId(id: string): boolean {
  return IMPORT_ID_RE.test(id);
}

export function importedRootOf(home: string): string {
  return join(home, 'imported');
}

export function importedDirOf(home: string, importId: string): string {
  if (!isImportId(importId)) throw new ZipImportError(`invalid import id: "${importId}"`);
  return join(importedRootOf(home), importId);
}

function newImportId(): string {
  return `imp_${randomBytes(6).toString('hex')}`;
}

/**
 * Extract a debug zip into a fresh `imported/<id>/` directory and validate it
 * looks like a session bundle. On any failure the partial directory is removed
 * so a bad upload never lingers in the imported list. Returns the bundle's
 * `import-meta.json` contents.
 */
export async function importSessionZip(
  home: string,
  zipBuffer: Buffer,
  originalName: string | null,
  now: Date,
): Promise<ImportInfo> {
  const importId = newImportId();
  const dir = importedDirOf(home, importId);
  await mkdir(dir, { recursive: true });

  try {
    await extractZip(zipBuffer, dir);

    // A debug bundle must contain a main wire; without it there is nothing to
    // visualize. `state.json` / `manifest.json` are best-effort.
    const hasMainWire = await pathExists(join(dir, 'agents', 'main', 'wire.jsonl'));
    if (!hasMainWire) {
      throw new ZipImportError(
        'zip does not look like a kimi-code session bundle (missing agents/main/wire.jsonl)',
      );
    }

    const manifest = await readManifest(dir);
    const meta: ImportInfo = {
      importId,
      importedAt: now.toISOString(),
      originalName: originalName !== null && originalName.length > 0 ? originalName : null,
      manifest,
    };
    await writeFile(join(dir, META_FILE), JSON.stringify(meta, null, 2), 'utf8');
    return meta;
  } catch (error) {
    await rm(dir, { recursive: true, force: true }).catch(() => {});
    throw error instanceof ZipImportError ? error : new ZipImportError((error as Error).message);
  }
}

/** Enumerate imported bundle ids (newest-first by directory mtime). */
export async function listImportedIds(home: string): Promise<string[]> {
  const root = importedRootOf(home);
  let entries: import('node:fs').Dirent[];
  try {
    entries = await readdir(root, { withFileTypes: true });
  } catch {
    return [];
  }
  const ids = entries
    .filter((e) => e.isDirectory() && isImportId(e.name))
    .map((e) => e.name);
  const withMtime = await Promise.all(
    ids.map(async (id) => {
      const mtime = await stat(join(root, id)).then((s) => s.mtimeMs).catch(() => 0);
      return { id, mtime };
    }),
  );
  return withMtime.toSorted((a, b) => b.mtime - a.mtime).map((x) => x.id);
}

export async function readImportMeta(home: string, importId: string): Promise<ImportInfo | null> {
  try {
    const raw = await readFile(join(importedDirOf(home, importId), META_FILE), 'utf8');
    const meta = JSON.parse(raw) as ImportInfo;
    // The sidecar is vis-written, but re-sanitize the manifest in case the
    // imported directory was hand-edited, so a corrupt type cannot reach the
    // session list and crash the UI.
    return { ...meta, manifest: meta.manifest ? sanitizeManifest(meta.manifest) : null };
  } catch {
    return null;
  }
}

export async function deleteImported(home: string, importId: string): Promise<boolean> {
  if (!isImportId(importId)) return false;
  const dir = importedDirOf(home, importId);
  if (!(await pathExists(dir))) return false;
  await rm(dir, { recursive: true, force: true });
  return true;
}

async function readManifest(dir: string): Promise<ImportManifest | null> {
  try {
    return sanitizeManifest(JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8')));
  } catch {
    return null;
  }
}

/** Declared string fields of {@link ImportManifest}. */
const MANIFEST_STRING_FIELDS = [
  'sessionId', 'exportedAt', 'kimiCodeVersion', 'wireProtocolVersion', 'os',
  'nodejsVersion', 'sessionFirstActivity', 'sessionLastActivity', 'title',
  'workspaceDir', 'sessionLogPath', 'globalLogPath', 'desktopLogPath',
  'webLogPath', 'desktopVersion', 'installSource',
] as const;

const SHELL_ENV_STRING_FIELDS = [
  'term',
  'termProgram',
  'termProgramVersion',
  'multiplexer',
  'shell',
] as const;

/**
 * Coerce an untrusted manifest object so every declared string field is either
 * a string or absent. A type-corrupt bundle (e.g. `{ "workspaceDir": 123 }`)
 * would otherwise propagate a non-string into `SessionSummary.workDir`, where
 * the session rail calls `.split('/')` and crashes the whole list.
 */
function sanitizeManifest(raw: unknown): ImportManifest | null {
  if (typeof raw !== 'object' || raw === null) return null;
  const o = raw as Record<string, unknown>;
  const m: Record<string, unknown> = {};
  for (const field of MANIFEST_STRING_FIELDS) {
    if (typeof o[field] === 'string') m[field] = o[field];
  }
  const shellEnv = o['shellEnv'];
  if (typeof shellEnv === 'object' && shellEnv !== null && !Array.isArray(shellEnv)) {
    const source = shellEnv as Record<string, unknown>;
    const sanitized: Record<string, string> = {};
    for (const field of SHELL_ENV_STRING_FIELDS) {
      if (typeof source[field] === 'string') sanitized[field] = source[field];
    }
    m['shellEnv'] = sanitized;
  }
  return m as ImportManifest;
}

async function pathExists(p: string): Promise<boolean> {
  try {
    await stat(p);
    return true;
  } catch {
    return false;
  }
}