diff --git a/apps/vis/server/package.json b/apps/vis/server/package.json index 45a71d338..1d792bb9b 100644 --- a/apps/vis/server/package.json +++ b/apps/vis/server/package.json @@ -34,10 +34,14 @@ "@hono/node-server": "^1.13.7", "@moonshot-ai/agent-core": "workspace:^", "@moonshot-ai/kosong": "workspace:^", - "hono": "^4.7.7" + "hono": "^4.7.7", + "yauzl": "^3.3.0" }, "devDependencies": { + "@types/yauzl": "^2.10.3", + "@types/yazl": "^2.4.6", "tsx": "^4.21.0", - "vitest": "4.1.4" + "vitest": "4.1.4", + "yazl": "^3.3.1" } } diff --git a/apps/vis/server/src/app.ts b/apps/vis/server/src/app.ts index 68014bdec..8d59ba17e 100644 --- a/apps/vis/server/src/app.ts +++ b/apps/vis/server/src/app.ts @@ -8,9 +8,13 @@ import { KIMI_CODE_HOME } from './config'; import { serveWebAsset, type WebAsset } from './lib/web-asset'; import { blobsRoute } from './routes/blobs'; import { contextRoute } from './routes/context'; +import { cronRoute } from './routes/cron'; +import { importsRoute } from './routes/imports'; +import { logsRoute } from './routes/logs'; import { sessionDetailRoute } from './routes/session-detail'; import { sessionsRoute } from './routes/sessions'; import { subagentsRoute } from './routes/subagents'; +import { tasksRoute } from './routes/tasks'; import { wireRoute } from './routes/wire'; /** Resolve the SPA bundle directory next to the compiled server.mjs, if it @@ -96,6 +100,10 @@ export async function createApp(options: CreateAppOptions = {}): Promise { api.route('/sessions', wireRoute(home)); api.route('/sessions', subagentsRoute(home)); api.route('/sessions', blobsRoute(home)); + api.route('/sessions', tasksRoute(home)); + api.route('/sessions', cronRoute(home)); + api.route('/sessions', logsRoute(home)); + api.route('/imports', importsRoute(home)); // Mount contextRoute last because it currently uses a catch-all stub // (Phase C scope) that would otherwise shadow more specific routes // registered below it. diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts index ad8a7c9af..6d7ef505e 100644 --- a/apps/vis/server/src/lib/agent-record-types.ts +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -16,14 +16,74 @@ export type { LoopRecordedEvent, ContextMessage, PromptOrigin, + // Background-task shapes are part of agent-core's public surface, so the + // visualizer tracks them directly instead of duplicating the union. + BackgroundTaskInfo, + BackgroundTaskStatus, + ProcessBackgroundTaskInfo, + AgentBackgroundTaskInfo, + QuestionBackgroundTaskInfo, } from '@moonshot-ai/agent-core'; export { AGENT_WIRE_PROTOCOL_VERSION } from '@moonshot-ai/agent-core'; export type { Message, ContentPart, ToolCall, TokenUsage } from '@moonshot-ai/kosong'; -// Local binding for the `AgentRecord` type used by the vis-only DTOs below -// (e.g. `WireEntry.data`). The `export type { … }` re-export above forwards -// the name to consumers but does NOT bring it into this module's scope. -import type { AgentRecord } from '@moonshot-ai/agent-core'; +// Local bindings for the upstream types referenced by the vis-only DTOs +// below. The `export type { … }` re-export above forwards the names to +// consumers but does NOT bring them into this module's scope. +import type { AgentRecord, BackgroundTaskInfo } from '@moonshot-ai/agent-core'; + +/** + * Persistent representation of a cron task. + * + * Structural mirror of agent-core's `CronTask` (`tools/cron/types.ts`), + * which is NOT re-exported from the package entry point. The shape is + * tiny and frozen; `cron-store.test.ts` reads a fixture written in the + * real on-disk format so the mirror cannot silently drift from disk. + */ +export interface CronTask { + readonly id: string; + readonly cron: string; + readonly prompt: string; + readonly createdAt: number; + readonly recurring?: boolean; + readonly lastFiredAt?: number; +} + +/** + * `manifest.json` shape inside a `/export-debug-zip` bundle. Structural + * mirror of agent-core's `ExportSessionManifest` (`rpc/core-api.ts`), which + * is not re-exported from the package entry. All fields optional-tolerant + * because the manifest comes from another machine / kimi-code version. + */ +export interface ImportManifest { + sessionId?: string; + exportedAt?: string; + kimiCodeVersion?: string; + wireProtocolVersion?: string; + os?: string; + nodejsVersion?: string; + sessionFirstActivity?: string; + sessionLastActivity?: string; + title?: string; + workspaceDir?: string; + sessionLogPath?: string; + globalLogPath?: string; + installSource?: string; + shellEnv?: unknown; +} + +/** vis-side bookkeeping for one imported bundle, written to + * `imported//import-meta.json`. */ +export interface ImportInfo { + /** vis-generated id (`imp_…`); also the session id the UI addresses. */ + importId: string; + /** ISO time the zip was imported into vis. */ + importedAt: string; + /** Original uploaded file name, when known. */ + originalName: string | null; + /** Parsed `manifest.json`, when present and readable. */ + manifest: ImportManifest | null; +} // ── vis-only DTOs ────────────────────────────────────────────────────────── @@ -58,6 +118,10 @@ export interface SessionSummary { mainWireRecordCount: number; wireProtocolVersion: string | null; health: SessionHealth; + /** True for sessions imported from a debug zip (under `/imported/`). */ + imported: boolean; + /** Export/import provenance for imported sessions; null for local ones. */ + importMeta: ImportInfo | null; } export interface AgentInfo { @@ -84,6 +148,10 @@ export interface SessionDetail { workDir: string; state: unknown; // 原样透传,前端按 state.json 真实形状渲染 agents: AgentInfo[]; + /** True for sessions imported from a debug zip. */ + imported: boolean; + /** Export/import provenance for imported sessions; null for local ones. */ + importMeta: ImportInfo | null; } /** One line of `wire.jsonl` after vis has parsed (and possibly migrated) @@ -122,3 +190,84 @@ export interface AgentTreeResponse { sessionId: string; tree: AgentNode[]; } + +// ── background tasks & cron ───────────────────────────────────────────────── + +/** A persisted background task plus vis-derived `output.log` metadata. + * `task` is the normalized agent-core shape; the size/exists fields let the + * UI badge how much output a task produced and offer a "view log" affordance + * without first fetching the (potentially large) log body. */ +export interface BackgroundTaskEntry { + task: BackgroundTaskInfo; + /** Which agent persisted this task — tasks live under the spawning agent's + * homedir (`/agents//tasks`), not the session root. */ + agentId: string; + /** Total byte size of the task's `output.log` (0 when absent). */ + outputSizeBytes: number; + /** Whether an `output.log` file exists for this task. */ + outputExists: boolean; +} + +export interface BackgroundTasksResponse { + sessionId: string; + tasks: BackgroundTaskEntry[]; +} + +/** One byte-window of a task's `output.log`. Byte-level (not line-level) + * paging mirrors how the log is stored on disk, so arbitrarily large logs + * can be paged without loading the whole file. */ +export interface TaskOutputResponse { + sessionId: string; + taskId: string; + /** Byte offset this window starts at. */ + offset: number; + /** Byte offset immediately after this window; pass as the next `offset` + * to page forward without drift. */ + nextOffset: number; + /** Total byte size of the log on disk. */ + size: number; + /** UTF-8 decoded window content. */ + content: string; + /** True when this window reaches the end of the log. */ + eof: boolean; +} + +export interface CronTasksResponse { + sessionId: string; + cron: CronTask[]; +} + +// ── imported sessions & logs ──────────────────────────────────────────────── + +/** Result of importing a debug zip. */ +export interface ImportResult { + /** The `imp_…` id the UI uses to address the imported session. */ + sessionId: string; + importMeta: ImportInfo; +} + +/** One parsed line of a diagnostic log. */ +export interface LogLine { + /** 1-indexed line number in the source log. */ + lineNo: number; + /** ISO timestamp parsed from the line prefix, or null if unparseable. */ + time: string | null; + /** Log level (INFO / WARN / ERROR / DEBUG / …), uppercased, or null. */ + level: string | null; + /** The human message between the level and the structured fields. */ + message: string; + /** Parsed trailing `key=value` fields. */ + fields: Record; + /** The original line, verbatim. */ + raw: string; +} + +export interface LogsResponse { + sessionId: string; + which: 'session' | 'global'; + /** Which logs exist on disk for this session. */ + available: { session: boolean; global: boolean }; + lines: LogLine[]; + /** True when the log was longer than the served cap and got truncated. */ + truncated: boolean; +} diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index fd7a376e6..f4511fa74 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -171,12 +171,16 @@ export function projectContext( // Absolute context-window fill, mirroring agent-core // ContextMemory._tokenCount: the latest step.end usage REPLACES the // snapshot (it is not cumulative — see Task P1.7 note on byScope). + // A zero-usage step.end (e.g. a content-filtered response) is the one + // exception agent-core makes — it keeps the prior count instead of + // resetting to 0 — so guard against a false drop here too. if ('usage' in ev && ev.usage !== undefined) { - contextTokens = + const fill = ev.usage.inputCacheRead + ev.usage.inputCacheCreation + ev.usage.inputOther + ev.usage.output; + if (fill > 0) contextTokens = fill; } openSteps.delete(ev.uuid); } else if (ev.type === 'tool.result') { diff --git a/apps/vis/server/src/lib/cron-store.ts b/apps/vis/server/src/lib/cron-store.ts new file mode 100644 index 000000000..78209bdd9 --- /dev/null +++ b/apps/vis/server/src/lib/cron-store.ts @@ -0,0 +1,67 @@ +// apps/vis/server/src/lib/cron-store.ts +// +// Read-only reader for cron tasks, persisted by agent-core under each (non-sub) +// agent's homedir at `/cron/.json` (callers pass the agent +// homedir, `/agents/`). The visualizer never writes these files; +// it mirrors agent-core's on-disk layout (tools/cron/persist.ts) for reading. + +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { CronTask } from './agent-record-types'; + +/** Cron id format: 8 lowercase hex chars (mirror of agent-core's cron-id + * shape). Enforced before joining a path so a stray / hand-edited filename + * cannot escape the cron directory. */ +const VALID_CRON_ID = /^[0-9a-f]{8}$/; + +export function isSafeCronId(id: string): boolean { + return VALID_CRON_ID.test(id); +} + +function cronDirOf(agentDir: string): string { + return join(agentDir, 'cron'); +} + +/** + * Enumerate all persisted cron tasks for a session, sorted by creation time + * (oldest first, matching how a user scheduled them). + * + * Silently skips filenames that don't match `VALID_CRON_ID`, files that fail + * to read/parse, and records missing the required cron fields. + */ +export async function listCronTasks(agentDir: string): Promise { + const dir = cronDirOf(agentDir); + let entries: import('node:fs').Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + const out: CronTask[] = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const id = entry.name.slice(0, -'.json'.length); + if (!VALID_CRON_ID.test(id)) continue; + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(join(dir, entry.name), 'utf8')); + } catch { + continue; + } + if (isCronTask(parsed)) out.push(parsed); + } + out.sort((a, b) => a.createdAt - b.createdAt); + return out; +} + +function isCronTask(value: unknown): value is CronTask { + if (typeof value !== 'object' || value === null) return false; + const o = value as Record; + return ( + typeof o['id'] === 'string' && + typeof o['cron'] === 'string' && + typeof o['prompt'] === 'string' && + typeof o['createdAt'] === 'number' + ); +} diff --git a/apps/vis/server/src/lib/import-store.ts b/apps/vis/server/src/lib/import-store.ts new file mode 100644 index 000000000..be63e8321 --- /dev/null +++ b/apps/vis/server/src/lib/import-store.ts @@ -0,0 +1,167 @@ +// apps/vis/server/src/lib/import-store.ts +// +// Imported debug bundles (`/export-debug-zip` zips) live under +// `/imported//`, unzipped to the same on-disk shape as a real +// session directory (state.json, agents//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//` 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 { + 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 { + 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 { + 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 { + 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 { + try { + return sanitizeManifest(JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'))); + } catch { + return null; + } +} + +/** Declared string fields of {@link ImportManifest}. `shellEnv` is free-form. */ +const MANIFEST_STRING_FIELDS = [ + 'sessionId', 'exportedAt', 'kimiCodeVersion', 'wireProtocolVersion', 'os', + 'nodejsVersion', 'sessionFirstActivity', 'sessionLastActivity', 'title', + 'workspaceDir', 'sessionLogPath', 'globalLogPath', 'installSource', +] 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; + const m: Record = {}; + for (const field of MANIFEST_STRING_FIELDS) { + if (typeof o[field] === 'string') m[field] = o[field]; + } + if (o['shellEnv'] !== undefined) m['shellEnv'] = o['shellEnv']; + return m as ImportManifest; +} + +async function pathExists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} diff --git a/apps/vis/server/src/lib/log-reader.ts b/apps/vis/server/src/lib/log-reader.ts new file mode 100644 index 000000000..dfe6a2d87 --- /dev/null +++ b/apps/vis/server/src/lib/log-reader.ts @@ -0,0 +1,122 @@ +// apps/vis/server/src/lib/log-reader.ts +// +// Parse a kimi-code diagnostic log into structured lines for the Logs view. +// +// Lines look like: +// 2026-06-15T05:32:08.722Z INFO llm config turnStep=0.1 provider=openai … +// i.e. ` `. Anything that does not +// match (continuation lines, stack traces) is kept verbatim as a level-less, +// time-less message so nothing is dropped. + +import { readdir, readFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import type { LogLine } from './agent-record-types'; + +/** Cap served lines so a multi-hundred-MB log cannot blow up the response. + * When exceeded we keep the TAIL (most recent), where failures usually are. */ +const MAX_LINES = 20_000; + +const LINE_RE = /^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s+([A-Za-z]+)\s+(.*)$/; +const FIELD_START_RE = /(^|\s)[A-Za-z_][\w.-]*=/; +const FIELD_RE = /([A-Za-z_][\w.-]*)=(\S+)/g; + +export interface LogReadResult { + lines: LogLine[]; + truncated: boolean; +} + +/** + * Discover a base log file plus its rotated siblings (``, `.1`, + * `.2`, …) in chronological order, oldest first. + * + * agent-core rotates by renaming the active file to `.1` and bumping older + * archives to higher numbers (`sinks.ts` rotate()), so the un-suffixed file is + * newest and `.N` is oldest. A bundle whose active log has already rotated + * away may contain only `.1`, etc. — which the Logs tab must still find. + */ +export async function discoverLogFiles(baseLogPath: string): Promise { + const dir = dirname(baseLogPath); + const base = basename(baseLogPath); + let names: string[]; + try { + names = (await readdir(dir, { withFileTypes: true })) + .filter((e) => e.isFile()) + .map((e) => e.name); + } catch { + return []; + } + let hasActive = false; + const rotated: { n: number; name: string }[] = []; + const prefix = `${base}.`; + for (const name of names) { + if (name === base) { + hasActive = true; + continue; + } + if (name.startsWith(prefix)) { + const suffix = name.slice(prefix.length); + if (/^\d+$/.test(suffix)) rotated.push({ n: Number(suffix), name }); + } + } + rotated.sort((a, b) => b.n - a.n); // highest index == oldest → first + const ordered = rotated.map((r) => join(dir, r.name)); + if (hasActive) ordered.push(join(dir, base)); // active is newest → last + return ordered; +} + +/** + * Read and parse the given log files in order, concatenated into one structured + * stream with continuous line numbers. Returns null when none could be read. + * The MAX_LINES tail cap applies across the combined set. + */ +export async function readLogs( + paths: readonly string[], + maxLines = MAX_LINES, +): Promise { + const allLines: string[] = []; + let read = 0; + for (const path of paths) { + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch { + continue; + } + read += 1; + const lines = raw.split(/\r?\n/); + // Drop a single trailing empty line from each file's final newline. + if (lines.length > 0 && lines.at(-1) === '') lines.pop(); + for (const line of lines) allLines.push(line); + } + if (read === 0) return null; + + const truncated = allLines.length > maxLines; + const startLineNo = truncated ? allLines.length - maxLines : 0; + const slice = truncated ? allLines.slice(startLineNo) : allLines; + + const lines: LogLine[] = slice.map((text, i) => parseLogLine(text, startLineNo + i + 1)); + return { lines, truncated }; +} + +export function parseLogLine(raw: string, lineNo: number): LogLine { + const m = LINE_RE.exec(raw); + if (m === null) { + return { lineNo, time: null, level: null, message: raw, fields: {}, raw }; + } + const time = m[1]!; + const level = m[2]!.toUpperCase(); + const rest = m[3]!; + + const fields: Record = {}; + let message = rest; + const fieldStart = rest.search(FIELD_START_RE); + if (fieldStart >= 0) { + message = rest.slice(0, fieldStart).trim(); + const fieldsPart = rest.slice(fieldStart); + for (const fm of fieldsPart.matchAll(FIELD_RE)) { + fields[fm[1]!] = fm[2]!; + } + } + return { lineNo, time, level, message: message.trim(), fields, raw }; +} diff --git a/apps/vis/server/src/lib/session-store.ts b/apps/vis/server/src/lib/session-store.ts index 4c5ea1245..f10f7ad9d 100644 --- a/apps/vis/server/src/lib/session-store.ts +++ b/apps/vis/server/src/lib/session-store.ts @@ -3,8 +3,9 @@ import { readdir, readFile, stat } from 'node:fs/promises'; import { join, resolve, sep } from 'node:path'; import { createInterface } from 'node:readline'; -import type { SessionSummary, SessionDetail, AgentInfo, SessionHealth } from './agent-record-types'; +import type { SessionSummary, SessionDetail, AgentInfo, SessionHealth, ImportInfo } from './agent-record-types'; import { compareAgentIds } from './agent-tree'; +import { importedDirOf, isImportId, listImportedIds, readImportMeta } from './import-store'; const SESSION_ID_RE = /^session_[A-Za-z0-9._-]+$/; const AGENT_ID_RE = /^[A-Za-z0-9._-]+$/; @@ -24,7 +25,10 @@ interface StateJson { title?: string; isCustomTitle?: boolean; lastPrompt?: string; - agents?: Record; + // Agent metadata comes from an untrusted state.json (a corrupt or imported + // bundle may hold non-object entries like `{ "main": null }`), so the value + // type allows null and inventoryAgents skips anything that isn't an object. + agents?: Record; custom?: Record; } @@ -45,11 +49,21 @@ export async function listSessions(home: string): Promise { if (summary !== null) out.push(summary); } } + // Imported debug bundles live under /imported// and surface + // in the same list, tagged so the UI can filter them. + for (const importId of await listImportedIds(home)) { + const dir = importedDirOf(home, importId); + const meta = await readImportMeta(home, importId); + const workDir = meta?.manifest?.workspaceDir ?? ''; + const summary = await tryReadSummary(dir, importId, workDir, { imported: true, importMeta: meta }); + if (summary !== null) out.push(summary); + } out.sort((a, b) => b.updatedAt - a.updatedAt); return out; } export async function readSessionDetail(home: string, sessionId: string): Promise { + if (isImportId(sessionId)) return readImportedDetail(home, sessionId); const sessionDir = await findSessionDir(home, sessionId); if (sessionDir === null) return null; const index = await readSessionIndex(home); @@ -62,11 +76,38 @@ export async function readSessionDetail(home: string, sessionId: string): Promis // still inspect the wire/context of a session whose state is corrupt. if (state === null) { const agents = await discoverAgentsFromDisk(sessionDir); - return { sessionId, sessionDir, workDir, state: null, agents }; + return { sessionId, sessionDir, workDir, state: null, agents, imported: false, importMeta: null }; } if (state.custom?.['imported_from_kimi_cli'] === true) return null; const agents = await inventoryAgents(sessionDir, state); - return { sessionId, sessionDir, workDir, state, agents }; + return { sessionId, sessionDir, workDir, state, agents, imported: false, importMeta: null }; +} + +/** Detail for an imported bundle. Same readers as a local session, but the + * directory is `imported//`, the workDir comes from the manifest, and + * agent homedirs are re-derived from the local extraction (state.json holds + * the exporting machine's absolute paths, which do not exist here). The + * `imported_from_kimi_cli` hide-filter is intentionally NOT applied — the + * user imported this bundle deliberately. */ +async function readImportedDetail(home: string, importId: string): Promise { + const sessionDir = importedDirOf(home, importId); + if (!(await pathExists(sessionDir))) return null; + const meta = await readImportMeta(home, importId); + const workDir = meta?.manifest?.workspaceDir ?? ''; + const state = await readState(sessionDir); + if (state === null) { + const agents = await discoverAgentsFromDisk(sessionDir); + return { sessionId: importId, sessionDir, workDir, state: null, agents, imported: true, importMeta: meta }; + } + // State is best-effort in a bundle: a readable state.json may still omit the + // `agents` map. When the inventory comes back empty, fall back to probing + // `agents/*` on disk so routes that require an agent (wire/context/…) still + // resolve `main`. + let agents = await inventoryAgents(sessionDir, state, true); + if (agents.length === 0) { + agents = await discoverAgentsFromDisk(sessionDir); + } + return { sessionId: importId, sessionDir, workDir, state, agents, imported: true, importMeta: meta }; } /** Fallback inventory used when `state.json` is unreadable: walk @@ -115,12 +156,21 @@ async function discoverAgentsFromDisk(sessionDir: string): Promise return out.sort((a, b) => compareAgentIds(a.agentId, b.agentId)); } -async function tryReadSummary(sessionDir: string, sessionId: string, workDir: string): Promise { +async function tryReadSummary( + sessionDir: string, + sessionId: string, + workDir: string, + opts: { imported?: boolean; importMeta?: ImportInfo | null } = {}, +): Promise { + const imported = opts.imported ?? false; + const importMeta = opts.importMeta ?? null; const state = await readState(sessionDir); if (state === null) { - return brokenStateSummary(sessionDir, sessionId, workDir); + return brokenStateSummary(sessionDir, sessionId, workDir, imported, importMeta); } - if (state.custom?.['imported_from_kimi_cli'] === true) return null; + // Local migrated-CLI sessions are hidden; an imported bundle is shown + // regardless because the user chose to import it. + if (!imported && state.custom?.['imported_from_kimi_cli'] === true) return null; const mainWirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); const mainExists = await pathExists(mainWirePath); @@ -156,16 +206,25 @@ async function tryReadSummary(sessionDir: string, sessionId: string, workDir: st mainWireRecordCount: mainCount, wireProtocolVersion: protocolVersion, health, + imported, + importMeta, }; } -function brokenStateSummary(sessionDir: string, sessionId: string, workDir: string): SessionSummary { +function brokenStateSummary( + sessionDir: string, + sessionId: string, + workDir: string, + imported = false, + importMeta: ImportInfo | null = null, +): SessionSummary { return { sessionId, sessionDir, workDir, title: null, lastPrompt: null, isCustomTitle: false, createdAt: 0, updatedAt: 0, agentCount: 0, mainAgentExists: false, mainWireRecordCount: 0, wireProtocolVersion: null, health: 'broken_state', + imported, importMeta, }; } @@ -195,10 +254,14 @@ async function readSessionIndex(home: string): Promise { +async function inventoryAgents(sessionDir: string, state: StateJson, deriveHomedir = false): Promise { const result: AgentInfo[] = []; for (const [id, meta] of Object.entries(state.agents ?? {})) { if (!isSafeAgentId(id)) continue; + // A type-corrupt entry (e.g. `{ "main": null }`) must not throw on the + // field dereferences below; skip it so the empty-inventory fallback in + // readImportedDetail can recover the agent from disk instead. + if (typeof meta !== 'object' || meta === null) continue; const wirePath = join(sessionDir, 'agents', id, 'wire.jsonl'); const exists = await pathExists(wirePath); let readable = exists; @@ -218,7 +281,10 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise/tasks/.json` +// (+ `tasks//output.log`) — NOT the session root. Callers pass the +// agent homedir (`/agents/`). +// +// The visualizer never writes these files; it mirrors agent-core's on-disk +// layout (background/persist.ts) for reading only: +// - the same `VALID_TASK_ID` guard, so a corrupt / hand-edited filename +// cannot turn a log path into a traversal primitive; +// - the same legacy snake_case → current camelCase normalization, so old +// sessions list identically to how the CLI would list them. + +import { open, readdir, readFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { + BackgroundTaskInfo, + BackgroundTaskStatus, +} from './agent-record-types'; + +/** Task id format: `{prefix}-{8 chars of [0-9a-z]}`. Mirror of agent-core's + * `VALID_TASK_ID` (background/persist.ts). Enforced before deriving any + * output path so neither `../` nor a legacy `bg_` id can escape. */ +const VALID_TASK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-z]{8}$/; + +export function isSafeTaskId(id: string): boolean { + return VALID_TASK_ID.test(id); +} + +function tasksDirOf(agentDir: string): string { + return join(agentDir, 'tasks'); +} + +function taskOutputFile(agentDir: string, taskId: string): string { + if (!VALID_TASK_ID.test(taskId)) { + throw new Error(`Invalid task id: "${taskId}"`); + } + return join(tasksDirOf(agentDir), taskId, 'output.log'); +} + +/** + * Enumerate all persisted background tasks for a session, normalized to the + * current `BackgroundTaskInfo` shape and sorted newest-first by start time. + * + * Silently skips: filenames that don't match `VALID_TASK_ID`, files that fail + * to read/parse, and records that are neither the current nor the legacy + * task shape — matching agent-core's tolerant `listTasks`. + */ +export async function listBackgroundTasks( + agentDir: string, +): Promise { + const dir = tasksDirOf(agentDir); + let entries: import('node:fs').Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + const out: BackgroundTaskInfo[] = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const id = entry.name.slice(0, -'.json'.length); + if (!VALID_TASK_ID.test(id)) continue; + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(join(dir, entry.name), 'utf8')); + } catch { + continue; + } + if (!isReadablePersistedTask(parsed)) continue; + try { + out.push(normalizePersistedTask(parsed)); + } catch { + // A record can pass the shape guard but still hold type-corrupt fields + // (e.g. a legacy `stop_reason` that is a number). Honour the + // silently-skips contract instead of failing the whole listing. + continue; + } + } + // Newest first; tasks with no start time sort last. + out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)); + return out; +} + +/** Byte size of a task's `output.log` (0 when absent or unreadable). */ +export async function taskOutputSizeBytes( + agentDir: string, + taskId: string, +): Promise { + try { + return (await stat(taskOutputFile(agentDir, taskId))).size; + } catch { + return 0; + } +} + +export interface TaskOutputWindow { + /** Byte offset this window starts at (clamped to >= 0). */ + offset: number; + /** Byte offset immediately after this window — pass it as the next + * `offset` to page forward. Exact (server-computed bytesRead), so paging + * never drifts even if a window boundary splits a multi-byte char. */ + nextOffset: number; + /** Total byte size of the log on disk. */ + size: number; + /** UTF-8 decoded window content. */ + content: string; + /** True when this window reaches EOF. */ + eof: boolean; +} + +/** + * Read a byte window of a task's `output.log`. + * + * Reads at most `maxBytes` bytes starting at byte `offset`. A window past EOF + * is clamped to whatever remains; an offset at/after EOF yields empty content. + * Mirrors agent-core's `readTaskOutputBytes` so large logs page identically. + */ +export async function readTaskOutput( + agentDir: string, + taskId: string, + offset: number, + maxBytes: number, +): Promise { + const start = Math.max(0, Math.trunc(offset)); + const limit = Math.max(0, Math.trunc(maxBytes)); + let handle; + try { + handle = await open(taskOutputFile(agentDir, taskId), 'r'); + } catch { + return { offset: start, nextOffset: start, size: 0, content: '', eof: true }; + } + try { + const size = (await handle.stat()).size; + if (limit === 0 || start >= size) { + return { offset: start, nextOffset: start, size, content: '', eof: start >= size }; + } + const length = Math.min(limit, size - start); + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(buffer, 0, length, start); + const content = buffer.toString('utf-8', 0, bytesRead); + const nextOffset = start + bytesRead; + return { offset: start, nextOffset, size, content, eof: nextOffset >= size }; + } catch { + return { offset: start, nextOffset: start, size: 0, content: '', eof: true }; + } finally { + await handle.close(); + } +} + +// ── normalization (ported from agent-core/agent/background/persist.ts) ─────── + +type LegacyBackgroundTaskStatus = + | 'running' + | 'awaiting_approval' + | 'completed' + | 'failed' + | 'killed' + | 'lost'; + +interface LegacyPersistedTask { + readonly task_id: string; + readonly command: string; + readonly description: string; + readonly pid: number; + readonly started_at: number; + readonly ended_at: number | null; + readonly exit_code: number | null; + readonly status: LegacyBackgroundTaskStatus; + readonly timed_out?: boolean; + readonly stop_reason?: string; + readonly timeout_ms?: number; + readonly agent_id?: string; + readonly subagent_type?: string; +} + +type DiskPersistedTask = BackgroundTaskInfo | LegacyPersistedTask; + +function normalizePersistedTask(task: DiskPersistedTask): BackgroundTaskInfo { + if (isLegacyPersistedTask(task)) return legacyPersistedTaskToInfo(task); + return { ...task, detached: task.detached ?? true }; +} + +function legacyPersistedTaskToInfo(task: LegacyPersistedTask): BackgroundTaskInfo { + const status = legacyStatusToCurrent(task); + const base = { + taskId: task.task_id, + description: task.description, + status, + detached: true, + startedAt: task.started_at, + endedAt: task.ended_at, + stopReason: optionalNonEmptyString(task.stop_reason), + timeoutMs: typeof task.timeout_ms === 'number' ? task.timeout_ms : undefined, + }; + if (task.task_id.startsWith('agent-')) { + return { + ...base, + kind: 'agent', + agentId: optionalNonEmptyString(task.agent_id), + subagentType: optionalNonEmptyString(task.subagent_type), + }; + } + return { + ...base, + kind: 'process', + command: task.command, + pid: task.pid, + exitCode: task.exit_code, + }; +} + +function legacyStatusToCurrent(task: LegacyPersistedTask): BackgroundTaskStatus { + if (task.status === 'awaiting_approval') return 'running'; + if (task.status === 'failed' && task.timed_out === true) return 'timed_out'; + return task.status; +} + +function isReadablePersistedTask(obj: unknown): obj is DiskPersistedTask { + return ( + isRecord(obj) && + (typeof obj['taskId'] === 'string' || typeof obj['task_id'] === 'string') + ); +} + +function isLegacyPersistedTask(task: DiskPersistedTask): task is LegacyPersistedTask { + return 'task_id' in task; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function optionalNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/apps/vis/server/src/lib/zip-import.ts b/apps/vis/server/src/lib/zip-import.ts new file mode 100644 index 000000000..55c85539f --- /dev/null +++ b/apps/vis/server/src/lib/zip-import.ts @@ -0,0 +1,130 @@ +// apps/vis/server/src/lib/zip-import.ts +// +// Safe extraction of a user-supplied debug zip into a destination directory. +// +// The zip comes from someone else's machine via `/export-debug-zip`, so it is +// untrusted input: every entry path is validated against path traversal +// ("zip slip"), and total entry-count / uncompressed-size caps guard against +// zip bombs. Only regular files are written; directories are created lazily. + +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { dirname, resolve, sep } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +import { fromBuffer, type Entry, type ZipFile } from 'yauzl'; + +export interface ExtractOptions { + /** Reject once this many entries have been seen. */ + readonly maxEntries?: number; + /** Reject once the summed uncompressed size exceeds this many bytes. */ + readonly maxTotalBytes?: number; +} + +const DEFAULT_MAX_ENTRIES = 50_000; +const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB + +export class ZipImportError extends Error {} + +/** + * Resolve a zip entry name to an absolute path under `root`, or return null + * when the entry would escape it (zip slip). Exposed for direct testing of + * the path-traversal guard, which is otherwise hard to exercise because zip + * writers refuse to emit `..` entries. + */ +export function resolveSafeTarget(root: string, entryName: string): string | null { + const absRoot = resolve(root); + const rootPrefix = absRoot + sep; + const rel = entryName.replaceAll('\\', '/'); + const target = resolve(absRoot, rel); + if (target !== absRoot && !target.startsWith(rootPrefix)) return null; + return target; +} + +/** + * Extract every file entry of `zipBuffer` under `destDir`, returning the list + * of written zip-relative paths (forward-slashed). `destDir` must already be a + * safe, caller-owned directory; this function never writes outside it. + */ +export async function extractZip( + zipBuffer: Buffer, + destDir: string, + options: ExtractOptions = {}, +): Promise { + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES; + const root = resolve(destDir); + + const zip = await openZip(zipBuffer); + const written: string[] = []; + let entryCount = 0; + let totalBytes = 0; + + return new Promise((resolvePromise, reject) => { + const fail = (message: string): void => { + zip.close(); + reject(new ZipImportError(message)); + }; + + zip.readEntry(); + zip.on('entry', (entry: Entry) => { + entryCount += 1; + if (entryCount > maxEntries) { + fail(`zip has too many entries (> ${maxEntries})`); + return; + } + totalBytes += entry.uncompressedSize; + if (totalBytes > maxTotalBytes) { + fail(`zip uncompressed size exceeds ${maxTotalBytes} bytes`); + return; + } + + // Directory entries end with '/'. Files inside still create their dirs. + if (entry.fileName.endsWith('/')) { + zip.readEntry(); + return; + } + + const rel = entry.fileName.replaceAll('\\', '/'); + const target = resolveSafeTarget(root, rel); + if (target === null) { + fail(`zip entry escapes the import directory: "${entry.fileName}"`); + return; + } + + zip.openReadStream(entry, (err, readStream) => { + if (err !== null || readStream === undefined) { + fail(`failed to read zip entry "${entry.fileName}": ${err?.message ?? 'unknown'}`); + return; + } + void mkdir(dirname(target), { recursive: true }) + .then(() => pipeline(readStream, createWriteStream(target))) + .then(() => { + written.push(rel); + zip.readEntry(); + }) + .catch((error: unknown) => { + fail(`failed to write "${rel}": ${(error as Error).message}`); + }); + }); + }); + zip.on('end', () => { + resolvePromise(written); + }); + zip.on('error', (err: Error) => { + reject(new ZipImportError(`corrupt zip: ${err.message}`)); + }); + }); +} + +function openZip(buffer: Buffer): Promise { + return new Promise((resolvePromise, reject) => { + fromBuffer(buffer, { lazyEntries: true }, (err, zipfile) => { + if (err !== null || zipfile === undefined) { + reject(new ZipImportError(`not a valid zip file: ${err?.message ?? 'unknown'}`)); + return; + } + resolvePromise(zipfile); + }); + }); +} diff --git a/apps/vis/server/src/routes/cron.ts b/apps/vis/server/src/routes/cron.ts new file mode 100644 index 000000000..e868bbd90 --- /dev/null +++ b/apps/vis/server/src/routes/cron.ts @@ -0,0 +1,32 @@ +import { Hono } from 'hono'; + +import { KIMI_CODE_HOME } from '../config'; +import type { CronTask } from '../lib/agent-record-types'; +import { readSessionDetail } from '../lib/session-store'; +import { listCronTasks } from '../lib/cron-store'; + +export function cronRoute(home: string = KIMI_CODE_HOME): Hono { + const r = new Hono(); + r.get('/:id/cron', async (c) => { + const id = c.req.param('id'); + const detail = await readSessionDetail(home, id); + if (!detail) { + return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); + } + // Cron jobs are persisted under each (non-sub) agent's homedir at + // `/cron`, not the session root. Aggregate across agents; sub + // agents have no cron directory and simply contribute nothing. + const cron: CronTask[] = []; + const seen = new Set(); + for (const agent of detail.agents) { + for (const job of await listCronTasks(agent.homedir)) { + if (seen.has(job.id)) continue; + seen.add(job.id); + cron.push(job); + } + } + cron.sort((a, b) => a.createdAt - b.createdAt); + return c.json({ sessionId: id, cron }); + }); + return r; +} diff --git a/apps/vis/server/src/routes/imports.ts b/apps/vis/server/src/routes/imports.ts new file mode 100644 index 000000000..322bbfa1a --- /dev/null +++ b/apps/vis/server/src/routes/imports.ts @@ -0,0 +1,47 @@ +import { Hono } from 'hono'; + +import { KIMI_CODE_HOME } from '../config'; +import { importSessionZip } from '../lib/import-store'; +import { ZipImportError } from '../lib/zip-import'; + +/** Reject obviously-too-large uploads before buffering the whole body. The zip + * itself (compressed) is capped here; the uncompressed cap lives in + * `extractZip`. */ +const MAX_ZIP_BYTES = 500 * 1024 * 1024; // 500 MiB + +export function importsRoute(home: string = KIMI_CODE_HOME): Hono { + const r = new Hono(); + + // Upload a `/export-debug-zip` bundle. The raw zip bytes are the request + // body; the original filename may be passed via `?name=` for display. + r.post('/', async (c) => { + const declared = Number(c.req.header('content-length') ?? '0'); + if (Number.isFinite(declared) && declared > MAX_ZIP_BYTES) { + return c.json({ error: 'zip is too large', code: 'BAD_REQUEST' }, 400); + } + const name = c.req.query('name') ?? null; + let buffer: Buffer; + try { + buffer = Buffer.from(await c.req.arrayBuffer()); + } catch { + return c.json({ error: 'could not read upload body', code: 'BAD_REQUEST' }, 400); + } + if (buffer.length === 0) { + return c.json({ error: 'empty upload', code: 'BAD_REQUEST' }, 400); + } + if (buffer.length > MAX_ZIP_BYTES) { + return c.json({ error: 'zip is too large', code: 'BAD_REQUEST' }, 400); + } + try { + const meta = await importSessionZip(home, buffer, name, new Date()); + return c.json({ sessionId: meta.importId, importMeta: meta }); + } catch (error) { + if (error instanceof ZipImportError) { + return c.json({ error: error.message, code: 'BAD_REQUEST' }, 400); + } + return c.json({ error: (error as Error).message, code: 'READ_ERROR' }, 500); + } + }); + + return r; +} diff --git a/apps/vis/server/src/routes/logs.ts b/apps/vis/server/src/routes/logs.ts new file mode 100644 index 000000000..8540324cf --- /dev/null +++ b/apps/vis/server/src/routes/logs.ts @@ -0,0 +1,48 @@ +import { Hono } from 'hono'; +import { join } from 'node:path'; + +import { KIMI_CODE_HOME } from '../config'; +import { discoverLogFiles, readLogs } from '../lib/log-reader'; +import { readSessionDetail } from '../lib/session-store'; + +const SESSION_LOG_REL = ['logs', 'kimi-code.log'] as const; +const GLOBAL_LOG_REL = ['logs', 'global', 'kimi-code.log'] as const; +const HOME_GLOBAL_LOG_REL = ['logs', 'kimi-code.log'] as const; + +export function logsRoute(home: string = KIMI_CODE_HOME): Hono { + const r = new Hono(); + r.get('/:id/logs', async (c) => { + const id = c.req.param('id'); + const which = c.req.query('which') === 'global' ? 'global' : 'session'; + const detail = await readSessionDetail(home, id); + if (!detail) { + return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); + } + const sessionLog = join(detail.sessionDir, ...SESSION_LOG_REL); + // The global diagnostic log is a single shared file. In an exported bundle + // it is captured under the session dir (logs/global/kimi-code.log); for a + // live local session it lives at /logs/kimi-code.log + // (agent-core's resolveGlobalLogPath), NOT under the session dir. + const globalLog = detail.imported + ? join(detail.sessionDir, ...GLOBAL_LOG_REL) + : join(home, ...HOME_GLOBAL_LOG_REL); + // Either log may have rotated (kimi-code.log.1, .2, …); discover the active + // file plus its archives so a bundle with only rotated logs still surfaces. + const sessionFiles = await discoverLogFiles(sessionLog); + const globalFiles = await discoverLogFiles(globalLog); + const available = { + session: sessionFiles.length > 0, + global: globalFiles.length > 0, + }; + const targetFiles = which === 'global' ? globalFiles : sessionFiles; + const result = await readLogs(targetFiles); + return c.json({ + sessionId: id, + which, + available, + lines: result?.lines ?? [], + truncated: result?.truncated ?? false, + }); + }); + return r; +} diff --git a/apps/vis/server/src/routes/tasks.ts b/apps/vis/server/src/routes/tasks.ts new file mode 100644 index 000000000..894a0f5e5 --- /dev/null +++ b/apps/vis/server/src/routes/tasks.ts @@ -0,0 +1,90 @@ +import { Hono } from 'hono'; + +import { KIMI_CODE_HOME } from '../config'; +import type { BackgroundTaskEntry } from '../lib/agent-record-types'; +import { readSessionDetail } from '../lib/session-store'; +import { + isSafeTaskId, + listBackgroundTasks, + readTaskOutput, + taskOutputSizeBytes, +} from '../lib/task-store'; + +/** Default output-log window size: 256 KiB. Large enough to show a whole + * typical log in one fetch, bounded so a multi-MB log pages instead of + * loading wholesale. Overridable via `?limit=`. */ +const DEFAULT_OUTPUT_LIMIT = 256 * 1024; +const MAX_OUTPUT_LIMIT = 4 * 1024 * 1024; + +export function tasksRoute(home: string = KIMI_CODE_HOME): Hono { + const r = new Hono(); + + // List background tasks (process / agent / question) for a session. Tasks are + // persisted under each spawning agent's homedir (`/tasks`), NOT the + // session root, so aggregate across every agent in the session. + r.get('/:id/tasks', async (c) => { + const id = c.req.param('id'); + const detail = await readSessionDetail(home, id); + if (!detail) { + return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); + } + const entries: BackgroundTaskEntry[] = []; + for (const agent of detail.agents) { + const tasks = await listBackgroundTasks(agent.homedir); + for (const task of tasks) { + const outputSizeBytes = await taskOutputSizeBytes(agent.homedir, task.taskId); + entries.push({ task, agentId: agent.agentId, outputSizeBytes, outputExists: outputSizeBytes > 0 }); + } + } + // Newest first across all agents. + entries.sort((a, b) => (b.task.startedAt ?? 0) - (a.task.startedAt ?? 0)); + return c.json({ sessionId: id, tasks: entries }); + }); + + // Read a byte-window of a single task's output.log. The task may belong to + // any agent, so locate the agent whose tasks/ directory holds it. + r.get('/:id/tasks/:taskId/output', async (c) => { + const id = c.req.param('id'); + const taskId = c.req.param('taskId'); + if (!isSafeTaskId(taskId)) { + return c.json({ error: 'invalid task id', code: 'BAD_REQUEST' }, 400); + } + const offset = parseNonNegativeInt(c.req.query('offset'), 0); + const limit = Math.min( + parseNonNegativeInt(c.req.query('limit'), DEFAULT_OUTPUT_LIMIT), + MAX_OUTPUT_LIMIT, + ); + const detail = await readSessionDetail(home, id); + if (!detail) { + return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); + } + // Prefer the agent whose log actually has bytes; otherwise any agent's dir + // yields the same empty window. An explicit ?agent= short-circuits the scan. + const hinted = c.req.query('agent'); + let dir = detail.agents.find((a) => a.agentId === hinted)?.homedir ?? detail.agents[0]?.homedir ?? detail.sessionDir; + for (const agent of detail.agents) { + if ((await taskOutputSizeBytes(agent.homedir, taskId)) > 0) { + dir = agent.homedir; + break; + } + } + const window = await readTaskOutput(dir, taskId, offset, limit); + return c.json({ + sessionId: id, + taskId, + offset: window.offset, + nextOffset: window.nextOffset, + size: window.size, + content: window.content, + eof: window.eof, + }); + }); + + return r; +} + +function parseNonNegativeInt(raw: string | undefined, fallback: number): number { + if (raw === undefined) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} diff --git a/apps/vis/server/test/lib/context-projector.test.ts b/apps/vis/server/test/lib/context-projector.test.ts index d2a2d3f4c..e3eb40fc4 100644 --- a/apps/vis/server/test/lib/context-projector.test.ts +++ b/apps/vis/server/test/lib/context-projector.test.ts @@ -127,6 +127,19 @@ describe('context-projector', () => { ]); }); + it('does not reset contextTokens on a zero-usage step.end', () => { + const entries = [ + { lineNo: 1, data: { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1', turnId: 'T', step: 0 } }, raw: {} }, + { lineNo: 2, data: { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's1', turnId: 'T', step: 0, usage: { inputOther: 100, output: 20, inputCacheRead: 80, inputCacheCreation: 0 } } }, raw: {} }, + { lineNo: 3, data: { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's2', turnId: 'T', step: 1 } }, raw: {} }, + // content-filtered response: usage all zero — must NOT reset the fill to 0. + { lineNo: 4, data: { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's2', turnId: 'T', step: 1, finishReason: 'filtered', usage: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } } }, raw: {} }, + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proj = projectContext(entries as any); + expect(proj.contextTokens).toBe(200); // step1 fill 200, kept across the zero step + }); + // ---- Fix G: tool.result content must match what the model saw --------------- // agent-core's `ContextMemory.appendLoopEvent` (`tool.result` case) stores // `createToolMessage(toolCallId, toolResultOutputForModel(event.result))`, NOT diff --git a/apps/vis/server/test/lib/cron-store.test.ts b/apps/vis/server/test/lib/cron-store.test.ts new file mode 100644 index 000000000..9b4cd11d0 --- /dev/null +++ b/apps/vis/server/test/lib/cron-store.test.ts @@ -0,0 +1,63 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { describe, it, expect, afterEach } from 'vitest'; + +import { buildSessionFixture } from '../fixtures/build'; +import { isSafeCronId, listCronTasks } from '../../src/lib/cron-store'; + +async function writeCron(sessionDir: string, fileName: string, body: unknown): Promise { + const dir = join(sessionDir, 'cron'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, fileName), JSON.stringify(body)); +} + +describe('cron-store', () => { + let cleanup: (() => Promise) | null = null; + afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; }); + + it('lists valid cron tasks sorted by creation time', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + // Written in the real on-disk shape — this doubles as a drift guard for + // the local CronTask mirror in agent-record-types.ts. + await writeCron(sessionDir, 'a1b2c3d4.json', { + id: 'a1b2c3d4', cron: '0 9 * * *', prompt: 'daily standup', + createdAt: 2000, recurring: true, lastFiredAt: 5000, + }); + await writeCron(sessionDir, 'beefbeef.json', { + id: 'beefbeef', cron: '*/5 * * * *', prompt: 'poll ci', + createdAt: 1000, recurring: false, + }); + + const cron = await listCronTasks(sessionDir); + expect(cron.map((t) => t.id)).toEqual(['beefbeef', 'a1b2c3d4']); // createdAt asc + expect(cron[1]).toMatchObject({ + cron: '0 9 * * *', prompt: 'daily standup', recurring: true, lastFiredAt: 5000, + }); + }); + + it('skips bad ids, corrupt json, and records missing required fields', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + await writeCron(sessionDir, 'NOTHEX12.json', { id: 'NOTHEX12', cron: 'x', prompt: 'p', createdAt: 1 }); + await mkdir(join(sessionDir, 'cron'), { recursive: true }); + await writeFile(join(sessionDir, 'cron', 'deadbeef.json'), '{ broken'); + await writeCron(sessionDir, 'cafecafe.json', { id: 'cafecafe', cron: '* * * * *' }); // no prompt/createdAt + expect(await listCronTasks(sessionDir)).toEqual([]); + }); + + it('returns [] when there is no cron directory', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + expect(await listCronTasks(sessionDir)).toEqual([]); + }); + + it('isSafeCronId accepts 8-hex ids only', () => { + expect(isSafeCronId('a1b2c3d4')).toBe(true); + expect(isSafeCronId('deadbeef')).toBe(true); + expect(isSafeCronId('DEADBEEF')).toBe(false); + expect(isSafeCronId('abc')).toBe(false); + expect(isSafeCronId('../escape')).toBe(false); + }); +}); diff --git a/apps/vis/server/test/lib/import-store.test.ts b/apps/vis/server/test/lib/import-store.test.ts new file mode 100644 index 000000000..8d5c3872f --- /dev/null +++ b/apps/vis/server/test/lib/import-store.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, afterEach } from 'vitest'; +import { ZipFile } from 'yazl'; + +import { importSessionZip, isImportId, listImportedIds, readImportMeta, deleteImported } from '../../src/lib/import-store'; +import { resolveSafeTarget } from '../../src/lib/zip-import'; + +/** Build an in-memory zip from a {path: contents} map (yazl refuses to emit + * `..` entries, so traversal is tested via resolveSafeTarget directly). */ +function buildZip(entries: Record): Promise { + return new Promise((resolve, reject) => { + const zip = new ZipFile(); + for (const [name, data] of Object.entries(entries)) { + zip.addBuffer(Buffer.from(data, 'utf8'), name); + } + zip.end(); + const chunks: Buffer[] = []; + (zip.outputStream as NodeJS.ReadableStream).on('data', (c: Buffer) => chunks.push(c)); + (zip.outputStream as NodeJS.ReadableStream).on('end', () => { resolve(Buffer.concat(chunks)); }); + (zip.outputStream as NodeJS.ReadableStream).on('error', reject); + }); +} + +const META_LINE = JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1 }); +const WIRE = `${META_LINE}\n`; + +function validBundle(): Record { + return { + 'manifest.json': JSON.stringify({ sessionId: 'session_orig', kimiCodeVersion: '0.20.2', workspaceDir: '/home/u/proj', title: 'imported demo' }), + 'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'imported demo', agents: { main: { homedir: '/orig/agents/main', type: 'main', parentAgentId: null } }, custom: {} }), + 'agents/main/wire.jsonl': WIRE, + 'logs/kimi-code.log': '2026-06-01T00:00:00.000Z INFO hello k=v\n', + }; +} + +describe('import-store', () => { + let home: string | null = null; + afterEach(async () => { if (home) await rm(home, { recursive: true, force: true }); home = null; }); + + it('imports a valid bundle and lists it with manifest metadata', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-import-')); + const zip = await buildZip(validBundle()); + const meta = await importSessionZip(home, zip, 'demo.zip', new Date('2026-06-29T00:00:00.000Z')); + + expect(isImportId(meta.importId)).toBe(true); + expect(meta.originalName).toBe('demo.zip'); + expect(meta.manifest?.sessionId).toBe('session_orig'); + expect(meta.manifest?.workspaceDir).toBe('/home/u/proj'); + + // Extracted to imported// with the session shape intact. + const dir = join(home, 'imported', meta.importId); + expect((await stat(join(dir, 'agents', 'main', 'wire.jsonl'))).isFile()).toBe(true); + expect((await stat(join(dir, 'logs', 'kimi-code.log'))).isFile()).toBe(true); + + const ids = await listImportedIds(home); + expect(ids).toContain(meta.importId); + const reread = await readImportMeta(home, meta.importId); + expect(reread?.importId).toBe(meta.importId); + }); + + it('rejects a zip with no main wire and cleans up', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-import-')); + const zip = await buildZip({ 'manifest.json': '{}', 'state.json': '{}' }); + await expect(importSessionZip(home, zip, null, new Date())).rejects.toThrow(/session bundle/); + // No partial directory left behind. + expect(await listImportedIds(home)).toEqual([]); + }); + + it('deletes an imported bundle', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-import-')); + const meta = await importSessionZip(home, await buildZip(validBundle()), null, new Date()); + expect(await deleteImported(home, meta.importId)).toBe(true); + expect(await listImportedIds(home)).toEqual([]); + expect(await deleteImported(home, meta.importId)).toBe(false); + }); + + it('isImportId only matches the imp_ scheme', () => { + expect(isImportId('imp_0123456789ab')).toBe(true); + expect(isImportId('session_abc')).toBe(false); + expect(isImportId('imp_xyz')).toBe(false); + expect(isImportId('../escape')).toBe(false); + }); +}); + +describe('resolveSafeTarget (zip-slip guard)', () => { + const root = '/tmp/imp/abc'; + it('accepts in-tree paths', () => { + expect(resolveSafeTarget(root, 'state.json')).toBe('/tmp/imp/abc/state.json'); + expect(resolveSafeTarget(root, 'agents/main/wire.jsonl')).toBe('/tmp/imp/abc/agents/main/wire.jsonl'); + }); + it('rejects traversal and absolute escapes', () => { + expect(resolveSafeTarget(root, '../evil')).toBeNull(); + expect(resolveSafeTarget(root, '../../etc/passwd')).toBeNull(); + expect(resolveSafeTarget(root, 'a/../../b')).toBeNull(); + expect(resolveSafeTarget(root, '/etc/passwd')).toBeNull(); + }); +}); diff --git a/apps/vis/server/test/lib/log-reader.test.ts b/apps/vis/server/test/lib/log-reader.test.ts new file mode 100644 index 000000000..9dc811a84 --- /dev/null +++ b/apps/vis/server/test/lib/log-reader.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; + +import { parseLogLine } from '../../src/lib/log-reader'; + +describe('parseLogLine', () => { + it('parses time, level, message, and trailing key=value fields', () => { + const line = '2026-06-15T05:32:08.722Z INFO llm config turnStep=0.1 provider=openai model=coding-model-okapi thinkingEffort=high'; + const parsed = parseLogLine(line, 7); + expect(parsed.lineNo).toBe(7); + expect(parsed.time).toBe('2026-06-15T05:32:08.722Z'); + expect(parsed.level).toBe('INFO'); + expect(parsed.message).toBe('llm config'); + expect(parsed.fields).toEqual({ + turnStep: '0.1', + provider: 'openai', + model: 'coding-model-okapi', + thinkingEffort: 'high', + }); + }); + + it('handles a message with no fields', () => { + const parsed = parseLogLine('2026-06-15T05:32:16.680Z WARN something happened', 1); + expect(parsed.level).toBe('WARN'); + expect(parsed.message).toBe('something happened'); + expect(parsed.fields).toEqual({}); + }); + + it('keeps unparseable lines verbatim as a message', () => { + const parsed = parseLogLine(' at someStackFrame (file.ts:1:2)', 3); + expect(parsed.time).toBeNull(); + expect(parsed.level).toBeNull(); + expect(parsed.message).toBe(' at someStackFrame (file.ts:1:2)'); + expect(parsed.raw).toBe(' at someStackFrame (file.ts:1:2)'); + }); +}); diff --git a/apps/vis/server/test/lib/task-store.test.ts b/apps/vis/server/test/lib/task-store.test.ts new file mode 100644 index 000000000..a4537fa4a --- /dev/null +++ b/apps/vis/server/test/lib/task-store.test.ts @@ -0,0 +1,155 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { describe, it, expect, afterEach } from 'vitest'; + +import { buildSessionFixture } from '../fixtures/build'; +import { + isSafeTaskId, + listBackgroundTasks, + readTaskOutput, + taskOutputSizeBytes, +} from '../../src/lib/task-store'; + +async function writeTask(sessionDir: string, fileName: string, body: unknown): Promise { + const dir = join(sessionDir, 'tasks'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, fileName), JSON.stringify(body)); +} + +describe('task-store', () => { + let cleanup: (() => Promise) | null = null; + afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; }); + + it('lists current-shape tasks of every kind, normalized and newest-first', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + + await writeTask(sessionDir, 'bash-aaaaaaaa.json', { + taskId: 'bash-aaaaaaaa', kind: 'process', description: 'run build', + command: 'pnpm build', pid: 4242, exitCode: 0, status: 'completed', + detached: true, startedAt: 1000, endedAt: 2000, + }); + await writeTask(sessionDir, 'agent-bbbbbbbb.json', { + taskId: 'agent-bbbbbbbb', kind: 'agent', description: 'explore repo', + agentId: 'agent-1', subagentType: 'Explore', status: 'running', + detached: true, startedAt: 3000, endedAt: null, + }); + await writeTask(sessionDir, 'question-cccccccc.json', { + taskId: 'question-cccccccc', kind: 'question', description: 'ask user', + questionCount: 2, status: 'running', detached: false, + startedAt: 2500, endedAt: null, + }); + + const tasks = await listBackgroundTasks(sessionDir); + expect(tasks.map((t) => t.taskId)).toEqual([ + 'agent-bbbbbbbb', // startedAt 3000 + 'question-cccccccc', // 2500 + 'bash-aaaaaaaa', // 1000 + ]); + const proc = tasks.find((t) => t.kind === 'process'); + expect(proc).toMatchObject({ command: 'pnpm build', pid: 4242, exitCode: 0 }); + const question = tasks.find((t) => t.kind === 'question'); + expect(question).toMatchObject({ questionCount: 2, detached: false }); + }); + + it('normalizes legacy snake_case tasks to the current shape', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + + await writeTask(sessionDir, 'bash-dddddddd.json', { + task_id: 'bash-dddddddd', command: 'sleep 1', description: 'legacy proc', + pid: 9, started_at: 100, ended_at: 200, exit_code: null, + status: 'failed', timed_out: true, timeout_ms: 5000, + }); + await writeTask(sessionDir, 'agent-eeeeeeee.json', { + task_id: 'agent-eeeeeeee', command: '', description: 'legacy agent', + pid: 0, started_at: 50, ended_at: null, exit_code: null, + status: 'awaiting_approval', agent_id: 'agent-2', subagent_type: 'general', + }); + + const tasks = await listBackgroundTasks(sessionDir); + const proc = tasks.find((t) => t.taskId === 'bash-dddddddd')!; + expect(proc.kind).toBe('process'); + expect(proc.status).toBe('timed_out'); // failed + timed_out → timed_out + expect(proc).toMatchObject({ detached: true, timeoutMs: 5000 }); + const agent = tasks.find((t) => t.taskId === 'agent-eeeeeeee')!; + expect(agent.kind).toBe('agent'); + expect(agent.status).toBe('running'); // awaiting_approval → running + expect(agent).toMatchObject({ agentId: 'agent-2', subagentType: 'general' }); + }); + + it('skips bad filenames, corrupt json, and unrecognized records', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + await writeTask(sessionDir, 'not-a-valid-id.json', { taskId: 'x', kind: 'process' }); + await mkdir(join(sessionDir, 'tasks'), { recursive: true }); + await writeFile(join(sessionDir, 'tasks', 'bash-ffffffff.json'), '{ broken'); + await writeTask(sessionDir, 'bash-99999999.json', { unrelated: true }); + expect(await listBackgroundTasks(sessionDir)).toEqual([]); + }); + + it('tolerates type-corrupt legacy fields instead of failing the whole listing', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + await writeTask(sessionDir, 'bash-aaaaaaaa.json', { + taskId: 'bash-aaaaaaaa', kind: 'process', description: 'ok', command: 'x', + pid: 1, exitCode: 0, status: 'completed', detached: true, startedAt: 100, endedAt: 200, + }); + // Passes the shape guard (has task_id) but stop_reason / subagent_type are + // numbers — the old code threw on `.trim()` and lost ALL tasks. + await writeTask(sessionDir, 'agent-bbbbbbbb.json', { + task_id: 'agent-bbbbbbbb', command: '', description: 'bad', pid: 0, + started_at: 50, ended_at: null, exit_code: null, status: 'failed', + stop_reason: 5, subagent_type: 5, + }); + + const tasks = await listBackgroundTasks(sessionDir); + // No throw; both tasks listed, the corrupt fields coerced away. + expect(tasks.map((t) => t.taskId).toSorted()).toEqual(['agent-bbbbbbbb', 'bash-aaaaaaaa']); + const bad = tasks.find((t) => t.taskId === 'agent-bbbbbbbb')!; + expect(bad.stopReason).toBeUndefined(); + expect(bad.kind === 'agent' ? bad.subagentType : 'n/a').toBeUndefined(); + }); + + it('returns [] when there is no tasks directory', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + expect(await listBackgroundTasks(sessionDir)).toEqual([]); + }); + + it('reads output.log byte windows with size + eof', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const dir = join(sessionDir, 'tasks', 'bash-12345678'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'output.log'), 'hello world'); + + expect(await taskOutputSizeBytes(sessionDir, 'bash-12345678')).toBe(11); + + const head = await readTaskOutput(sessionDir, 'bash-12345678', 0, 5); + expect(head).toMatchObject({ offset: 0, nextOffset: 5, size: 11, content: 'hello', eof: false }); + + // Paging forward from the previous window's nextOffset reaches EOF exactly. + const tail = await readTaskOutput(sessionDir, 'bash-12345678', head.nextOffset, 100); + expect(tail).toMatchObject({ offset: 5, nextOffset: 11, size: 11, content: ' world', eof: true }); + + const past = await readTaskOutput(sessionDir, 'bash-12345678', 50, 10); + expect(past).toMatchObject({ content: '', eof: true }); + }); + + it('returns an empty window when the log is absent', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const w = await readTaskOutput(sessionDir, 'bash-00000000', 0, 100); + expect(w).toMatchObject({ size: 0, content: '', eof: true }); + }); + + it('isSafeTaskId guards traversal', () => { + expect(isSafeTaskId('bash-1a2b3c4d')).toBe(true); + expect(isSafeTaskId('agent-deadbeef')).toBe(true); + expect(isSafeTaskId('../escape')).toBe(false); + expect(isSafeTaskId('bash')).toBe(false); + expect(isSafeTaskId('bg_abcd')).toBe(false); + }); +}); diff --git a/apps/vis/server/test/routes/cron.test.ts b/apps/vis/server/test/routes/cron.test.ts new file mode 100644 index 000000000..a349c29b7 --- /dev/null +++ b/apps/vis/server/test/routes/cron.test.ts @@ -0,0 +1,49 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { describe, it, expect, afterEach } from 'vitest'; + +import { buildSessionFixture } from '../fixtures/build'; +import { cronRoute } from '../../src/routes/cron'; + +describe('cron route', () => { + let cleanup: (() => Promise) | null = null; + afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; }); + + it('GET /:id/cron returns the persisted cron tasks', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + // Cron lives under the main agent's homedir, not the session root. + const dir = join(sessionDir, 'agents', 'main', 'cron'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'a1b2c3d4.json'), JSON.stringify({ + id: 'a1b2c3d4', cron: '0 9 * * *', prompt: 'standup', createdAt: 1, recurring: true, + })); + + const app = cronRoute(home); + const res = await app.request('/session_fixture/cron'); + expect(res.status).toBe(200); + const body = (await res.json()) as { sessionId: string; cron: { id: string; cron: string }[] }; + expect(body.sessionId).toBe('session_fixture'); + expect(body.cron).toHaveLength(1); + expect(body.cron[0]).toMatchObject({ id: 'a1b2c3d4', cron: '0 9 * * *', prompt: 'standup' }); + }); + + it('GET /:id/cron returns [] when there are no cron tasks', async () => { + const { home, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const app = cronRoute(home); + const res = await app.request('/session_fixture/cron'); + expect(res.status).toBe(200); + expect(((await res.json()) as { cron: unknown[] }).cron).toEqual([]); + }); + + it('returns 404 for a missing session', async () => { + const { home, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const app = cronRoute(home); + const res = await app.request('/no-such-session/cron'); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ code: 'NOT_FOUND' }); + }); +}); diff --git a/apps/vis/server/test/routes/imports.test.ts b/apps/vis/server/test/routes/imports.test.ts new file mode 100644 index 000000000..fb6d535de --- /dev/null +++ b/apps/vis/server/test/routes/imports.test.ts @@ -0,0 +1,152 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, afterEach } from 'vitest'; +import { ZipFile } from 'yazl'; + +import { importsRoute } from '../../src/routes/imports'; +import { logsRoute } from '../../src/routes/logs'; +import { sessionsRoute } from '../../src/routes/sessions'; +import { wireRoute } from '../../src/routes/wire'; + +function buildZip(entries: Record): Promise { + return new Promise((resolve, reject) => { + const zip = new ZipFile(); + for (const [name, data] of Object.entries(entries)) zip.addBuffer(Buffer.from(data, 'utf8'), name); + zip.end(); + const chunks: Buffer[] = []; + (zip.outputStream as NodeJS.ReadableStream).on('data', (c: Buffer) => chunks.push(c)); + (zip.outputStream as NodeJS.ReadableStream).on('end', () => { resolve(Buffer.concat(chunks)); }); + (zip.outputStream as NodeJS.ReadableStream).on('error', reject); + }); +} + +const META = JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1 }); +const PROMPT = JSON.stringify({ type: 'turn.prompt', time: 2, input: [{ type: 'text', text: 'hi' }], origin: { kind: 'user' } }); + +function bundle(): Record { + return { + 'manifest.json': JSON.stringify({ sessionId: 'session_orig', kimiCodeVersion: '0.20.2', workspaceDir: '/w/proj', title: 'demo' }), + 'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'demo', agents: { main: { homedir: '/orig/agents/main', type: 'main', parentAgentId: null } }, custom: {} }), + 'agents/main/wire.jsonl': `${META}\n${PROMPT}\n`, + 'logs/kimi-code.log': '2026-06-01T00:00:00.000Z INFO boot step=0\n2026-06-01T00:00:01.000Z ERROR oops code=500\n', + }; +} + +async function importBundle(home: string): Promise { + const app = importsRoute(home); + const res = await app.request('/?name=demo.zip', { method: 'POST', body: await buildZip(bundle()) }); + expect(res.status).toBe(200); + return ((await res.json()) as { sessionId: string }).sessionId; +} + +describe('imports + logs routes', () => { + let home: string | null = null; + afterEach(async () => { if (home) await rm(home, { recursive: true, force: true }); home = null; }); + + it('imports a zip and surfaces it in the session list tagged imported', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-imp-route-')); + const importId = await importBundle(home); + + const list = sessionsRoute(home); + const res = await list.request('/'); + const body = (await res.json()) as { sessions: { sessionId: string; imported: boolean; importMeta: { manifest: { kimiCodeVersion: string } | null } | null }[] }; + const imported = body.sessions.find((s) => s.sessionId === importId); + expect(imported).toBeDefined(); + expect(imported!.imported).toBe(true); + expect(imported!.importMeta?.manifest?.kimiCodeVersion).toBe('0.20.2'); + }); + + it('serves the imported session wire through the existing wire route', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-imp-route-')); + const importId = await importBundle(home); + const res = await wireRoute(home).request(`/${importId}/wire?agent=main`); + expect(res.status).toBe(200); + const body = (await res.json()) as { records: { data: { type: string } }[] }; + // metadata is the wire header; the one remaining record is the prompt. + expect(body.records.length).toBeGreaterThanOrEqual(1); + expect(body.records.some((r) => r.data.type === 'turn.prompt')).toBe(true); + }); + + it('parses the imported session log', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-imp-route-')); + const importId = await importBundle(home); + const res = await logsRoute(home).request(`/${importId}/logs`); + expect(res.status).toBe(200); + const body = (await res.json()) as { available: { session: boolean }; lines: { level: string | null; fields: Record }[] }; + expect(body.available.session).toBe(true); + expect(body.lines).toHaveLength(2); + expect(body.lines[1]!.level).toBe('ERROR'); + expect(body.lines[1]!.fields).toEqual({ code: '500' }); + }); + + it('rejects a non-zip upload with 400', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-imp-route-')); + const res = await importsRoute(home).request('/', { method: 'POST', body: Buffer.from('not a zip') }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('rejects an empty upload with 400', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-imp-route-')); + const res = await importsRoute(home).request('/', { method: 'POST', body: Buffer.alloc(0) }); + expect(res.status).toBe(400); + }); + + it('falls back to disk agent discovery when an imported bundle state omits agents', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-imp-route-')); + // Readable state.json, but no `agents` map (best-effort bundle). The main + // wire is present on disk, so the agent must still be discoverable. + const noAgents: Record = { + 'manifest.json': JSON.stringify({ sessionId: 'session_orig' }), + 'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'demo', custom: {} }), + 'agents/main/wire.jsonl': `${META}\n${PROMPT}\n`, + }; + const importRes = await importsRoute(home).request('/?name=x.zip', { method: 'POST', body: await buildZip(noAgents) }); + expect(importRes.status).toBe(200); + const importId = ((await importRes.json()) as { sessionId: string }).sessionId; + + // Despite the empty state.agents, the wire route resolves `main` via disk. + const wireRes = await wireRoute(home).request(`/${importId}/wire?agent=main`); + expect(wireRes.status).toBe(200); + expect(((await wireRes.json()) as { records: unknown[] }).records.length).toBeGreaterThanOrEqual(1); + }); + + it('falls back to disk discovery when an imported agents map is type-corrupt', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-imp-route-')); + // state.json present, agents map non-empty but the entry is null — must not + // 500; the on-disk main wire should still be discoverable. + const corruptAgents: Record = { + 'manifest.json': JSON.stringify({ sessionId: 'session_orig' }), + 'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'demo', agents: { main: null }, custom: {} }), + 'agents/main/wire.jsonl': `${META}\n${PROMPT}\n`, + }; + const importId = ((await (await importsRoute(home).request('/?name=x.zip', { method: 'POST', body: await buildZip(corruptAgents) })).json()) as { sessionId: string }).sessionId; + + const wireRes = await wireRoute(home).request(`/${importId}/wire?agent=main`); + expect(wireRes.status).toBe(200); + expect(((await wireRes.json()) as { records: unknown[] }).records.length).toBeGreaterThanOrEqual(1); + }); + + it('sanitizes type-corrupt manifest fields so the session list cannot crash', async () => { + home = await mkdtemp(join(tmpdir(), 'vis-imp-route-')); + const corrupt: Record = { + // workspaceDir / kimiCodeVersion are the wrong type — must not reach workDir. + 'manifest.json': JSON.stringify({ sessionId: 'session_orig', workspaceDir: 123, kimiCodeVersion: 7, title: 'demo' }), + 'state.json': JSON.stringify({ createdAt: '2026-06-01T00:00:00.000Z', updatedAt: '2026-06-01T01:00:00.000Z', title: 'demo', agents: { main: { homedir: '/o', type: 'main', parentAgentId: null } }, custom: {} }), + 'agents/main/wire.jsonl': `${META}\n${PROMPT}\n`, + }; + const importId = ((await (await importsRoute(home).request('/?name=x.zip', { method: 'POST', body: await buildZip(corrupt) })).json()) as { sessionId: string }).sessionId; + + const body = (await (await sessionsRoute(home).request('/')).json()) as { + sessions: { sessionId: string; workDir: unknown; importMeta: { manifest: { workspaceDir?: unknown; kimiCodeVersion?: unknown; sessionId?: unknown } | null } | null }[]; + }; + const s = body.sessions.find((x) => x.sessionId === importId)!; + expect(typeof s.workDir).toBe('string'); // not the number 123 + expect(s.workDir).toBe(''); + expect(s.importMeta?.manifest?.workspaceDir).toBeUndefined(); // dropped + expect(s.importMeta?.manifest?.kimiCodeVersion).toBeUndefined(); // dropped + expect(s.importMeta?.manifest?.sessionId).toBe('session_orig'); // valid string kept + }); +}); diff --git a/apps/vis/server/test/routes/logs.test.ts b/apps/vis/server/test/routes/logs.test.ts new file mode 100644 index 000000000..45911c0cb --- /dev/null +++ b/apps/vis/server/test/routes/logs.test.ts @@ -0,0 +1,78 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { describe, it, expect, afterEach } from 'vitest'; + +import { buildSessionFixture } from '../fixtures/build'; +import { logsRoute } from '../../src/routes/logs'; + +interface LogsBody { + available: { session: boolean; global: boolean }; + lines: { message: string; level: string | null; fields: Record }[]; +} + +describe('logs route (local sessions)', () => { + let cleanup: (() => Promise) | null = null; + afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; }); + + it('reads the session log from the session dir and the global log from KIMI_CODE_HOME', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + // Per-session log lives under the session dir… + await mkdir(join(sessionDir, 'logs'), { recursive: true }); + await writeFile(join(sessionDir, 'logs', 'kimi-code.log'), '2026-06-01T00:00:00.000Z INFO session boot k=v\n'); + // …but the shared global log lives at /logs/kimi-code.log, NOT under + // the session dir. Before the fix this was reported as unavailable. + await mkdir(join(home, 'logs'), { recursive: true }); + await writeFile(join(home, 'logs', 'kimi-code.log'), '2026-06-01T00:00:01.000Z WARN global thing g=1\n'); + + const app = logsRoute(home); + + const sessionRes = await app.request('/session_fixture/logs'); + expect(sessionRes.status).toBe(200); + const sb = (await sessionRes.json()) as LogsBody; + expect(sb.available).toEqual({ session: true, global: true }); + expect(sb.lines[0]!.message).toBe('session boot'); + + const globalRes = await app.request('/session_fixture/logs?which=global'); + expect(globalRes.status).toBe(200); + const gb = (await globalRes.json()) as LogsBody; + expect(gb.lines[0]!.message).toBe('global thing'); + expect(gb.lines[0]!.level).toBe('WARN'); + expect(gb.lines[0]!.fields).toEqual({ g: '1' }); + }); + + it('reports global unavailable for a local session with no home global log', async () => { + const { home, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const res = await logsRoute(home).request('/session_fixture/logs'); + expect(res.status).toBe(200); + expect(((await res.json()) as LogsBody).available.global).toBe(false); + }); + + it('discovers a rotated session log when the active file has rotated away', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + await mkdir(join(sessionDir, 'logs'), { recursive: true }); + // Only an archive exists — no active kimi-code.log. + await writeFile(join(sessionDir, 'logs', 'kimi-code.log.1'), '2026-06-01T00:00:00.000Z INFO rotated only r=1\n'); + + const res = await logsRoute(home).request('/session_fixture/logs'); + const b = (await res.json()) as LogsBody; + expect(b.available.session).toBe(true); + expect(b.lines[0]!.message).toBe('rotated only'); + }); + + it('concatenates rotated + active session logs oldest-first', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + await mkdir(join(sessionDir, 'logs'), { recursive: true }); + await writeFile(join(sessionDir, 'logs', 'kimi-code.log.2'), '2026-06-01T00:00:00.000Z INFO oldest\n'); + await writeFile(join(sessionDir, 'logs', 'kimi-code.log.1'), '2026-06-01T00:00:01.000Z INFO middle\n'); + await writeFile(join(sessionDir, 'logs', 'kimi-code.log'), '2026-06-01T00:00:02.000Z INFO newest\n'); + + const res = await logsRoute(home).request('/session_fixture/logs'); + const b = (await res.json()) as LogsBody; + expect(b.lines.map((l) => l.message)).toEqual(['oldest', 'middle', 'newest']); + }); +}); diff --git a/apps/vis/server/test/routes/tasks.test.ts b/apps/vis/server/test/routes/tasks.test.ts new file mode 100644 index 000000000..b760b662c --- /dev/null +++ b/apps/vis/server/test/routes/tasks.test.ts @@ -0,0 +1,97 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { describe, it, expect, afterEach } from 'vitest'; + +import { buildSessionFixture } from '../fixtures/build'; +import { tasksRoute } from '../../src/routes/tasks'; + +describe('tasks route', () => { + let cleanup: (() => Promise) | null = null; + afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; }); + + // Tasks live under the spawning agent's homedir (/agents/main/tasks), + // NOT the session root — seed there so the test mirrors real on-disk layout. + async function seed(sessionDir: string): Promise { + const dir = join(sessionDir, 'agents', 'main', 'tasks'); + await mkdir(join(dir, 'bash-12345678'), { recursive: true }); + await writeFile(join(dir, 'bash-12345678.json'), JSON.stringify({ + taskId: 'bash-12345678', kind: 'process', description: 'build', + command: 'pnpm build', pid: 7, exitCode: 0, status: 'completed', + detached: true, startedAt: 100, endedAt: 200, + })); + await writeFile(join(dir, 'bash-12345678', 'output.log'), 'line one\nline two\n'); + } + + it('GET /:id/tasks returns entries with output metadata', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + await seed(sessionDir); + + const app = tasksRoute(home); + const res = await app.request('/session_fixture/tasks'); + expect(res.status).toBe(200); + const body = (await res.json()) as { + sessionId: string; + tasks: { task: { taskId: string }; agentId: string; outputSizeBytes: number; outputExists: boolean }[]; + }; + expect(body.sessionId).toBe('session_fixture'); + expect(body.tasks).toHaveLength(1); + expect(body.tasks[0]!.task.taskId).toBe('bash-12345678'); + expect(body.tasks[0]!.agentId).toBe('main'); + expect(body.tasks[0]!.outputExists).toBe(true); + expect(body.tasks[0]!.outputSizeBytes).toBe('line one\nline two\n'.length); + }); + + it('GET /:id/tasks returns [] when there are no tasks', async () => { + const { home, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const app = tasksRoute(home); + const res = await app.request('/session_fixture/tasks'); + expect(res.status).toBe(200); + expect(((await res.json()) as { tasks: unknown[] }).tasks).toEqual([]); + }); + + it('GET /:id/tasks/:taskId/output pages by byte window', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + await seed(sessionDir); + const app = tasksRoute(home); + + const res = await app.request('/session_fixture/tasks/bash-12345678/output?offset=0&limit=8'); + expect(res.status).toBe(200); + const body = (await res.json()) as { content: string; size: number; eof: boolean; offset: number; nextOffset: number }; + expect(body.content).toBe('line one'); + expect(body.size).toBe(18); + expect(body.eof).toBe(false); + expect(body.offset).toBe(0); + expect(body.nextOffset).toBe(8); + }); + + it('GET output returns empty window for a task with no log', async () => { + const { home, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const app = tasksRoute(home); + const res = await app.request('/session_fixture/tasks/bash-00000000/output'); + expect(res.status).toBe(200); + expect((await res.json())).toMatchObject({ size: 0, content: '', eof: true }); + }); + + it('rejects an unsafe task id with 400', async () => { + const { home, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const app = tasksRoute(home); + const res = await app.request('/session_fixture/tasks/..%2Fescape/output'); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('returns 404 for a missing session', async () => { + const { home, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const app = tasksRoute(home); + const res = await app.request('/no-such-session/tasks'); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ code: 'NOT_FOUND' }); + }); +}); diff --git a/apps/vis/web/package.json b/apps/vis/web/package.json index 34df8a306..91be7434d 100644 --- a/apps/vis/web/package.json +++ b/apps/vis/web/package.json @@ -18,6 +18,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -35,6 +36,7 @@ "tailwindcss": "^4.1.4", "typescript": "6.0.2", "vite": "^6.3.3", - "vite-plugin-singlefile": "^2.3.3" + "vite-plugin-singlefile": "^2.3.3", + "vitest": "4.1.4" } } diff --git a/apps/vis/web/src/api.ts b/apps/vis/web/src/api.ts index ac20191a0..3764144f7 100644 --- a/apps/vis/web/src/api.ts +++ b/apps/vis/web/src/api.ts @@ -5,6 +5,11 @@ import type { WireResponse, ContextResponse, AgentTreeResponse, + BackgroundTasksResponse, + TaskOutputResponse, + CronTasksResponse, + ImportResult, + LogsResponse, ApiError, } from './types'; @@ -112,6 +117,48 @@ export const api = { getAgentTree: (id: string) => get(`/api/sessions/${enc(id)}/agents`), + /** Background tasks (process / agent / question) persisted under the + * session's `tasks/` directory, each with `output.log` metadata. */ + getTasks: (id: string) => + get(`/api/sessions/${enc(id)}/tasks`), + + /** A byte-window of a single task's `output.log`. */ + getTaskOutput: (id: string, taskId: string, offset = 0, limit?: number) => + get( + `/api/sessions/${enc(id)}/tasks/${enc(taskId)}/output?offset=${offset}` + + (limit !== undefined ? `&limit=${limit}` : ''), + ), + + /** Cron jobs persisted under the session's `cron/` directory. */ + getCron: (id: string) => + get(`/api/sessions/${enc(id)}/cron`), + + /** Parsed diagnostic log for a session (works for local and imported). */ + getLogs: (id: string, which: 'session' | 'global' = 'session') => + get(`/api/sessions/${enc(id)}/logs?which=${which}`), + + /** Import a `/export-debug-zip` bundle. Sends the raw file as the body. */ + importZip: async (file: File): Promise => { + const headers: Record = { accept: 'application/json' }; + const token = authToken(); + if (token !== null && token.length > 0) headers['authorization'] = `Bearer ${token}`; + const res = await fetch(`/api/imports?name=${enc(file.name)}`, { + method: 'POST', + headers, + body: file, + }); + if (!res.ok) { + let err: ApiError | null = null; + try { + err = (await res.json()) as ApiError; + } catch { + /* ignore */ + } + throw new Error(err?.error ?? `HTTP ${res.status} ${res.statusText}`); + } + return (await res.json()) as ImportResult; + }, + deleteSession: (id: string) => del(`/api/sessions/${enc(id)}`), /** Open the session's on-disk folder in the OS file manager. Side diff --git a/apps/vis/web/src/components/analysis/TimelineTab.tsx b/apps/vis/web/src/components/analysis/TimelineTab.tsx new file mode 100644 index 000000000..39c0cca38 --- /dev/null +++ b/apps/vis/web/src/components/analysis/TimelineTab.tsx @@ -0,0 +1,335 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useSession } from '../../hooks/useSession'; +import { useWire } from '../../hooks/useWire'; +import { + analyzeWire, + type Analysis, + type StepNode, + type ToolCallNode, + type TurnNode, +} from '../../lib/analysis'; +import type { WireEntry } from '../../types'; +import { formatBytes } from '../shared/SizePreview'; +import { formatDuration, formatTokens } from '../../util/time'; +import { Pill } from '../shared/Pill'; + +interface TimelineTabProps { + sessionId: string; +} + +/** Timeline tab — the agent's execution folded into turns → steps → tool + * calls, with the derived metrics the flat record list does not surface: + * durations, per-turn token cost, context-window growth, cache-hit rate, + * tool latency, truncation, and idle gaps. All computed client-side from + * the same wire the Wire tab fetches. */ +export function TimelineTab({ sessionId }: TimelineTabProps) { + const { data: detail } = useSession(sessionId); + const [agentId, setAgentId] = useState('main'); + // Reset the selected agent when navigating to another session while this tab + // stays mounted; otherwise a previously-selected subagent would 404 against + // the new session (mirrors WireTab/ContextTab). + useEffect(() => { + setAgentId('main'); + }, [sessionId]); + const { data: wire, isLoading, error } = useWire(sessionId, agentId); + + const analysis = useMemo(() => { + if (!wire) return null; + return analyzeWire(wire.records as WireEntry[]); + }, [wire]); + + const agents = detail?.agents ?? []; + + return ( +
+
+ +
+ + {isLoading ? ( +
analyzing…
+ ) : error ? ( +
{error.message}
+ ) : analysis === null || analysis.summary.turnCount === 0 ? ( +
no turns to analyze in this agent's wire
+ ) : ( +
+ + + + + +
+ turns · {analysis.turns.length} +
+ {analysis.turns.map((turn) => ( + + ))} +
+
+
+ )} +
+ ); +} + +function SectionTitle({ children }: { children: import('react').ReactNode }) { + return

{children}

; +} + +function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function SummaryGrid({ analysis }: { analysis: Analysis }) { + const s = analysis.summary; + const hit = analysis.cache.hitRate; + return ( +
+ + + + 0 ? 'var(--color-sev-error)' : undefined} + /> + + + + +
+ ); +} + +function ContextSparkline({ analysis }: { analysis: Analysis }) { + const pts = analysis.contextSeries; + if (pts.length < 2) return null; + const peak = analysis.summary.peakContextTokens || 1; + const W = 600; + const H = 44; + const dx = W / (pts.length - 1); + const path = pts + .map((p, i) => `${i === 0 ? 'M' : 'L'} ${(i * dx).toFixed(1)} ${(H - (p.contextTokens / peak) * H).toFixed(1)}`) + .join(' '); + return ( +
+ context-window fill over steps · peak {formatTokens(peak)} +
+ + + +
+ step 1 + step {pts.length} +
+
+
+ ); +} + +function ToolStatsTable({ analysis }: { analysis: Analysis }) { + if (analysis.toolStats.length === 0) return null; + return ( +
+ tool usage · {analysis.toolStats.length} distinct +
+ + + + + + + + + {analysis.toolStats.map((t) => ( + + + + + + + + + + ))} + +
toolcallserrorstruncatedavgmaxoutput
{t.name}{t.count} 0 ? 'var(--color-sev-error)' : undefined}>{t.errorCount} 0 ? 'var(--color-sev-warning)' : undefined}>{t.truncatedCount}{formatDuration(t.avgMs)}{formatDuration(t.maxMs)}{formatBytes(t.totalOutputBytes)}
+
+
+ ); +} + +function Th({ children, align = 'right' }: { children: import('react').ReactNode; align?: 'left' | 'right' }) { + return {children}; +} +function Td({ children, tone }: { children: import('react').ReactNode; tone?: string }) { + return {children}; +} + +function ConfigChanges({ analysis }: { analysis: Analysis }) { + if (analysis.configChanges.length === 0) return null; + return ( +
+ config changes · {analysis.configChanges.length} +
+ {analysis.configChanges.map((c) => ( +
+ line {c.lineNo} + {c.changed.map((ch) => ( + + {ch.field}={ch.value} + + ))} +
+ ))} +
+
+ ); +} + +function IdleGaps({ analysis }: { analysis: Analysis }) { + const gaps = analysis.idleGaps.slice(0, 5); + if (gaps.length === 0) return null; + return ( +
+ longest idle gaps +
+ {gaps.map((g, i) => ( +
+ + {g.kind === 'between_turns' ? 'waiting' : 'in-turn'} + + {formatDuration(g.gapMs)} + line {g.afterLineNo} → {g.beforeLineNo} +
+ ))} +
+
+ ); +} + +function TurnCard({ turn }: { turn: TurnNode }) { + const [open, setOpen] = useState(turn.index === 0); + const totalTokens = turn.tokens.inputOther + turn.tokens.output + turn.tokens.inputCacheRead + turn.tokens.inputCacheCreation; + return ( +
+ + + {open ? ( +
+ {turn.waitBeforeMs !== undefined && turn.waitBeforeMs >= 1000 ? ( +
+ ⏱ waited {formatDuration(turn.waitBeforeMs)} before this turn +
+ ) : null} +
+ {turn.steps.map((step) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +function StepRow({ step, turnDurationMs }: { step: StepNode; turnDurationMs?: number }) { + const widthPct = turnDurationMs && step.durationMs ? Math.max(2, (step.durationMs / turnDurationMs) * 100) : 0; + return ( +
+
+ step {step.step} + {step.finishReason ? ( + {step.finishReason} + ) : null} + {formatDuration(step.durationMs)} + {step.llmFirstTokenLatencyMs !== undefined ? ( + ttft {step.llmFirstTokenLatencyMs}ms + ) : null} + {step.contextTokens !== undefined ? ( + ctx {formatTokens(step.contextTokens)} + ) : null} + {step.content.thinkChars > 0 ? 💭 {step.content.thinkChars} : null} +
+ {widthPct > 0 ? ( +
+
+
+ ) : null} + {step.toolCalls.length > 0 ? ( +
+ {step.toolCalls.map((tc) => ( + + ))} +
+ ) : null} +
+ ); +} + +function ToolRow({ tc, stepDurationMs }: { tc: ToolCallNode; stepDurationMs?: number }) { + const widthPct = stepDurationMs && tc.durationMs ? Math.max(2, (tc.durationMs / stepDurationMs) * 100) : 0; + return ( +
+ {tc.name} + {formatDuration(tc.durationMs)} + {tc.outputBytes !== undefined ? ( + {formatBytes(tc.outputBytes)} + ) : null} + {tc.isError ? error : null} + {tc.truncated ? truncated : null} + {tc.resultLineNo === undefined ? no result : null} + {widthPct > 0 ? ( +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/vis/web/src/components/layout/AppShell.tsx b/apps/vis/web/src/components/layout/AppShell.tsx index 1d7fbeea2..51c9bbb02 100644 --- a/apps/vis/web/src/components/layout/AppShell.tsx +++ b/apps/vis/web/src/components/layout/AppShell.tsx @@ -40,7 +40,11 @@ export function AppShell({ children }: AppShellProps) {
-
{children}
+ {/* min-w-0 lets the main column shrink below its content's intrinsic + width; without it a flex child defaults to min-width:auto and wide + tab content (e.g. the Timeline's flex-wrap rows) blows the layout + out horizontally instead of wrapping. */} +
{children}
); diff --git a/apps/vis/web/src/components/logs/LogsTab.tsx b/apps/vis/web/src/components/logs/LogsTab.tsx new file mode 100644 index 000000000..be0c396e5 --- /dev/null +++ b/apps/vis/web/src/components/logs/LogsTab.tsx @@ -0,0 +1,190 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useMemo, useRef, useState } from 'react'; + +import { useLogs } from '../../hooks/useTasks'; +import type { LogLine } from '../../types'; +import { formatWallClock } from '../../util/time'; +import { Pill, type PillTone } from '../shared/Pill'; + +interface LogsTabProps { + sessionId: string; +} + +function levelTone(level: string | null): PillTone { + switch (level) { + case 'ERROR': + case 'FATAL': + return 'error'; + case 'WARN': + case 'WARNING': + return 'warning'; + case 'INFO': + return 'info'; + case 'DEBUG': + case 'TRACE': + return 'meta'; + default: + return 'neutral'; + } +} + +const LEVELS = ['ALL', 'ERROR', 'WARN', 'INFO', 'DEBUG'] as const; +type LevelFilter = (typeof LEVELS)[number]; + +function matchesLevel(line: LogLine, filter: LevelFilter): boolean { + if (filter === 'ALL') return true; + if (line.level === null) return false; + if (filter === 'WARN') return line.level === 'WARN' || line.level === 'WARNING'; + if (filter === 'ERROR') return line.level === 'ERROR' || line.level === 'FATAL'; + return line.level === filter; +} + +/** Logs tab — structured view of a session's diagnostic log. Works for both + * local sessions (whose dir holds `logs/kimi-code.log`) and imported bundles + * (which additionally may carry the global log). */ +export function LogsTab({ sessionId }: LogsTabProps) { + const [which, setWhich] = useState<'session' | 'global'>('session'); + const [level, setLevel] = useState('ALL'); + const [search, setSearch] = useState(''); + const { data, isLoading, error } = useLogs(sessionId, which); + const parentRef = useRef(null); + + const lines = data?.lines ?? []; + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return lines.filter((l) => { + if (!matchesLevel(l, level)) return false; + if (!q) return true; + return l.raw.toLowerCase().includes(q); + }); + }, [lines, level, search]); + + const virt = useVirtualizer({ + count: filtered.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 24, + overscan: 20, + getItemKey: (i) => filtered[i]?.lineNo ?? i, + }); + + const available = data?.available ?? { session: false, global: false }; + + return ( +
+
+
+ { setWhich('session'); }} disabled={!available.session && !isLoading}> + session + + { setWhich('global'); }} disabled={!available.global}> + global + +
+ + { setSearch(e.target.value); }} + className="w-64 border border-border bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-0 placeholder:text-fg-3 focus:border-border-strong focus:outline-none" + /> + + {filtered.length} / {lines.length} + {data?.truncated ? ' · tail' : ''} + +
+ + {isLoading ? ( +
loading log…
+ ) : error ? ( +
{error.message}
+ ) : lines.length === 0 ? ( +
+ {which === 'global' && !available.global + ? 'no global log in this bundle (export without --include-global-log)' + : 'no log available for this session'} +
+ ) : ( +
+ {data?.truncated ? ( +
+ log is large — showing the most recent {lines.length} lines +
+ ) : null} +
+ {virt.getVirtualItems().map((vi) => { + const line = filtered[vi.index]; + if (!line) return null; + return ( +
+ +
+ ); + })} +
+
+ )} +
+ ); +} + +function SegBtn({ active, onClick, disabled, children }: { active: boolean; onClick: () => void; disabled?: boolean; children: import('react').ReactNode }) { + return ( + + ); +} + +function LogRow({ line }: { line: LogLine }) { + const fieldKeys = Object.keys(line.fields); + return ( +
+ + {line.time ? formatWallClock(Date.parse(line.time)) : '—'} + + + {line.level ? ( + {line.level} + ) : null} + + + {line.message} + {fieldKeys.length > 0 ? ( + + {fieldKeys.map((k) => ( + + {k}={line.fields[k]} + + ))} + + ) : null} + +
+ ); +} diff --git a/apps/vis/web/src/components/sessions/SessionCard.tsx b/apps/vis/web/src/components/sessions/SessionCard.tsx index 3def30646..3635118a8 100644 --- a/apps/vis/web/src/components/sessions/SessionCard.tsx +++ b/apps/vis/web/src/components/sessions/SessionCard.tsx @@ -40,9 +40,22 @@ export function SessionCard({ session, onDelete, deleting }: SessionCardProps) {
{shortId} + {session.imported ? ( + + imported + + ) : null}
{formatRelativeTime(session.updatedAt)} @@ -60,6 +73,11 @@ export function SessionCard({ session, onDelete, deleting }: SessionCardProps) { {subagentCount}sub ) : null} + {session.imported && session.importMeta?.manifest?.kimiCodeVersion ? ( + + v{session.importMeta.manifest.kimiCodeVersion} + + ) : null} {session.health !== 'ok' ? ( {session.health} diff --git a/apps/vis/web/src/components/sessions/SessionFilter.tsx b/apps/vis/web/src/components/sessions/SessionFilter.tsx index 7cfa382f0..59fc34a35 100644 --- a/apps/vis/web/src/components/sessions/SessionFilter.tsx +++ b/apps/vis/web/src/components/sessions/SessionFilter.tsx @@ -1,4 +1,6 @@ -import type { SessionSortKey, HealthFilter } from './SessionRail'; +import { useRef } from 'react'; + +import type { SessionSortKey, HealthFilter, SourceFilter } from './SessionRail'; interface SessionFilterProps { search: string; @@ -7,8 +9,13 @@ interface SessionFilterProps { onSortChange: (v: SessionSortKey) => void; healthFilter: HealthFilter; onHealthChange: (v: HealthFilter) => void; + sourceFilter: SourceFilter; + onSourceChange: (v: SourceFilter) => void; totalCount: number; filteredCount: number; + importedCount: number; + onImport: (file: File) => void; + importing: boolean; } const SORT_OPTIONS: { value: SessionSortKey; label: string }[] = [ @@ -26,6 +33,12 @@ const HEALTH_OPTIONS: { value: HealthFilter; label: string }[] = [ { value: 'missing_main_wire', label: 'no wire' }, ]; +const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [ + { value: 'all', label: 'all' }, + { value: 'local', label: 'local' }, + { value: 'imported', label: 'imported' }, +]; + export function SessionFilter({ search, onSearchChange, @@ -33,11 +46,42 @@ export function SessionFilter({ onSortChange, healthFilter, onHealthChange, + sourceFilter, + onSourceChange, totalCount, filteredCount, + importedCount, + onImport, + importing, }: SessionFilterProps) { + const fileInput = useRef(null); return (
+
+ { + const file = e.target.files?.[0]; + if (file) onImport(file); + e.target.value = ''; + }} + /> + + {importedCount > 0 ? ( + {importedCount} imported + ) : null} +
+