From 525fb146d6e74ccdec9089b0397f88ef8e71bfbb Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 30 Jun 2026 13:40:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(vis):=20full=20session=20debugging=20?= =?UTF-8?q?=E2=80=94=20tasks/cron,=20execution=20timeline,=20retries=20&?= =?UTF-8?q?=20tool=20progress=20(#1210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(vis): surface background tasks and cron jobs The visualizer read every wire/state/blob artifact a session persists but ignored the two on-demand families agent-core also writes under the session directory: background tasks (tasks/.json + output.log) and cron jobs (cron/.json). Neither is reconstructable from the wire, so there was no way to inspect what a session spawned in the background or scheduled. Server: - task-store / cron-store read-only readers mirroring agent-core's on-disk layout, id-validation guard, and legacy snake_case task normalization - GET /:id/tasks, /:id/tasks/:taskId/output (byte-window paged via an exact nextOffset cursor), and /:id/cron routes - re-export the public background-task types from agent-core; mirror the non-exported CronTask shape with a fixture-backed drift test Web: - Tasks tab: process/agent/question kinds with status, timing, kind-specific fields, raw JSON, and a progressively paged output.log viewer - Cron tab: expression, prompt, recurring/one-shot, created/last-fired - count badges on both tabs Tests: +20 (lib + route), all 113 vis-server tests green; web typecheck and build clean. * feat(agent-core): persist step retries and tool progress summary Two transient signals were only ever emitted as live-only loop events, so nothing survived in the agent record for post-hoc analysis: - step retries: chatWithRetry gains an onRetry callback; turn-step collects the recovered attempts and attaches them to step.end as an optional `retries` array (previously only the live `step.retrying` event). - tool progress: tool-call distills a tool's sparse status/percent updates into a bounded `progress` summary (updateCount / lastStatus / maxPercent) on tool.result. Streamed stdout/stderr is excluded — it would bloat the wire and is already reflected in the result output. Both are additive optional fields, so the wire protocol version is unchanged and existing records keep loading. New public types: LoopStepRetryRecord, LoopToolProgressSummary. * feat(vis): add execution-analysis timeline and surface retries/progress Turn the debugger from a flat record viewer into an analysis tool. New Timeline tab: folds the wire into turns → steps → tool calls (client-side, no extra round-trip) and derives the metrics the raw list hides — per-turn / per-step / per-tool duration, per-turn token cost, a context-window fill sparkline with cache-hit rate, a tool usage table, idle-gap detection, and a config-change timeline. Inline elsewhere: - Wire rows show tool.call → tool.result elapsed time; tool.result detail shows truncation, output size, retries, and the progress summary. - Issues drawer gains tool-error, truncation, filtered, max_tokens, and retried categories. - Tasks tab links agent-kind tasks to the subagent's wire. Wires up vitest for the web package and adds analysis/issues unit tests. * feat(vis): import debug zips with a logs view and imported-session filtering A `/export-debug-zip` bundle is just `manifest.json` plus a flattened session directory, which vis already knows how to read. Importing one therefore lights up every existing tab for a session that lives on someone else's machine. Server: - zip-import: yauzl extraction with zip-slip path guards and entry-count / uncompressed-size caps for untrusted uploads. - import-store: extract a bundle into /imported//, validate it has a main wire, and record an import-meta.json sidecar. - session-store resolves imp_-prefixed ids against imported/, so wire / context / tasks / cron / blobs / logs all work on imported sessions; agent homedirs are re-derived locally (the bundle holds foreign absolute paths). - POST /api/imports (raw zip body) and GET /api/sessions/:id/logs (structured log lines — also available for local sessions). Web: - session rail: import button + all/local/imported filter + imported badge. - new Logs tab: virtualized, level filter, search, session/global toggle. - manifest card atop the State tab for imported sessions. SessionSummary/SessionDetail gain `imported` + `importMeta`. Tests cover extraction, the zip-slip guard, list merge, reading an imported wire through the existing route, and log parsing. * fix(vis): read tasks/cron from agent homedirs and stop persisting tool status text Addresses review feedback on the debug-tooling changes: - Background tasks and cron jobs are persisted under each agent's homedir (/agents//tasks and /cron), not the session root. The Tasks and Cron tabs read the session root, so they showed nothing for normal sessions. Both routes now aggregate across detail.agents homedirs; task entries carry the owning agentId. The route-test fixtures were writing to the wrong (session-root) location too — corrected to the real agents/main layout so they actually exercise the path. - tool.result progress no longer keeps free-form status text, only updateCount and maxPercent. A tool's status string can contain sensitive data (e.g. an MCP OAuth authorization URL) that must not leak into persisted wire files or exported debug bundles. * fix(vis): stop the main content area from overflowing horizontally The
flex child lacked min-w-0, so it defaulted to min-width:auto and refused to shrink below its content's intrinsic width. Tabs that lay out in normal flow with flex-wrap rows (the Timeline tab) then got unbounded width, never wrapped, and blew the layout out to thousands of pixels wide. Adding min-w-0 lets the column shrink to the available width so its content wraps, truncates, or scrolls within its own container. * fix(vis): resolve local global log path and imported-state agent fallback - Logs tab: for non-imported sessions the shared global log lives at /logs/kimi-code.log, not under the session dir (that path is only used inside exported bundles). The route now reads the home path for local sessions, so the global-log toggle works for them. - Imported detail: a bundle's state.json is best-effort and may omit the agents map. When the inventory is empty, fall back to discovering agents from disk so routes that require an agent (wire/context) still resolve main. * fix(vis): harden imported manifest and task parsing against corrupt input An imported debug zip is untrusted, so a syntactically valid but type-corrupt file could crash whole views: - manifest.json: a non-string field (e.g. workspaceDir: 123) flowed into SessionSummary.workDir, where the session rail calls .split('/') and crashed the entire list. readManifest/readImportMeta now sanitize declared string fields, keeping only strings. - task JSON: a record that passed the shape guard but held a non-string legacy field (e.g. stop_reason: 5) threw in normalization, failing GET /tasks with a 500 and hiding all of a session's tasks. optionalNonEmptyString now tolerates non-strings, and listBackgroundTasks skips any record that still fails to normalize — honouring the reader's documented silently-skips contract. * fix(vis): discover rotated logs; keep tool progress on thrown failures - Logs tab: the diagnostic log can rotate (kimi-code.log.1, .2, …) and an exported bundle may contain only the archives. The route now discovers the active file plus its rotated siblings and concatenates them oldest-first, so a rotated-away log still surfaces (covered by node-sdk's rotated-export case). - agent-core: a tool that reported sparse progress and then threw lost its progress summary, because the catch path built the error tool.result without it. Thread progressSummary through that path too, matching the success and malformed-return paths. * fix(vis): skip type-corrupt agent entries in imported state readImportedDetail's empty-inventory fallback never ran when a bundle's state.json had a non-empty but type-corrupt agents map (e.g. `{ "agents": { "main": null } }`): inventoryAgents dereferenced the null entry and threw, so readSessionDetail returned 500 instead of recovering main from the on-disk agents/main/wire.jsonl. inventoryAgents now skips non-object entries, letting the disk-discovery fallback take over. * fix(vis): reset timeline agent on session change; preserve context on zero-usage steps - Timeline tab kept the previously-selected agent id across session navigation, so a subagent selection would 404 against the next session. Reset it to main on sessionId change, mirroring WireTab/ContextTab. - A zero-usage step.end (e.g. a content-filtered response) reset the context-window fill to 0, pushing a false drop into the Timeline chart and the Context tab. agent-core's ContextMemory keeps the prior count in that case; the analysis lib and the context projector now do the same. * revert: drop agent-core retries/tool-progress persistence These were the only changes in this branch that touched agent-core. They persisted two previously live-only signals (step retries, tool progress) to the wire purely so the visualizer could display them — marginal features that did not justify modifying the core loop or extending the wire surface. Reverts the agent-core loop/type/export changes (restored to main, keeping #1209) and its changeset, and removes the vis-side rendering and types that consumed step.end.retries / tool.result.progress. The rest of vis is unchanged and reads only data agent-core already persists. --- apps/vis/server/package.json | 8 +- apps/vis/server/src/app.ts | 8 + apps/vis/server/src/lib/agent-record-types.ts | 157 +++++- apps/vis/server/src/lib/context-projector.ts | 6 +- apps/vis/server/src/lib/cron-store.ts | 67 +++ apps/vis/server/src/lib/import-store.ts | 167 ++++++ apps/vis/server/src/lib/log-reader.ts | 122 +++++ apps/vis/server/src/lib/session-store.ts | 86 +++- apps/vis/server/src/lib/task-store.ts | 240 +++++++++ apps/vis/server/src/lib/zip-import.ts | 130 +++++ apps/vis/server/src/routes/cron.ts | 32 ++ apps/vis/server/src/routes/imports.ts | 47 ++ apps/vis/server/src/routes/logs.ts | 48 ++ apps/vis/server/src/routes/tasks.ts | 90 ++++ .../server/test/lib/context-projector.test.ts | 13 + apps/vis/server/test/lib/cron-store.test.ts | 63 +++ apps/vis/server/test/lib/import-store.test.ts | 100 ++++ apps/vis/server/test/lib/log-reader.test.ts | 35 ++ apps/vis/server/test/lib/task-store.test.ts | 155 ++++++ apps/vis/server/test/routes/cron.test.ts | 49 ++ apps/vis/server/test/routes/imports.test.ts | 152 ++++++ apps/vis/server/test/routes/logs.test.ts | 78 +++ apps/vis/server/test/routes/tasks.test.ts | 97 ++++ apps/vis/web/package.json | 4 +- apps/vis/web/src/api.ts | 47 ++ .../src/components/analysis/TimelineTab.tsx | 335 ++++++++++++ .../web/src/components/layout/AppShell.tsx | 6 +- apps/vis/web/src/components/logs/LogsTab.tsx | 190 +++++++ .../src/components/sessions/SessionCard.tsx | 20 +- .../src/components/sessions/SessionFilter.tsx | 70 ++- .../src/components/sessions/SessionRail.tsx | 26 +- .../vis/web/src/components/state/StateTab.tsx | 51 +- apps/vis/web/src/components/tasks/CronTab.tsx | 96 ++++ .../vis/web/src/components/tasks/TasksTab.tsx | 270 ++++++++++ .../web/src/components/wire/IssuesDrawer.tsx | 4 + apps/vis/web/src/components/wire/WireRow.tsx | 52 +- apps/vis/web/src/components/wire/WireTab.tsx | 19 +- apps/vis/web/src/components/wire/parts.tsx | 7 + apps/vis/web/src/hooks/useSession.ts | 11 + apps/vis/web/src/hooks/useTasks.ts | 31 ++ apps/vis/web/src/lib/analysis.ts | 483 ++++++++++++++++++ apps/vis/web/src/lib/issues.ts | 44 ++ apps/vis/web/src/pages/SessionDetailPage.tsx | 35 +- apps/vis/web/src/types.ts | 15 + apps/vis/web/src/util/time.ts | 18 + apps/vis/web/test/analysis.test.ts | 134 +++++ apps/vis/web/test/issues.test.ts | 37 ++ apps/vis/web/vitest.config.ts | 9 + pnpm-lock.yaml | 15 + 49 files changed, 3926 insertions(+), 53 deletions(-) create mode 100644 apps/vis/server/src/lib/cron-store.ts create mode 100644 apps/vis/server/src/lib/import-store.ts create mode 100644 apps/vis/server/src/lib/log-reader.ts create mode 100644 apps/vis/server/src/lib/task-store.ts create mode 100644 apps/vis/server/src/lib/zip-import.ts create mode 100644 apps/vis/server/src/routes/cron.ts create mode 100644 apps/vis/server/src/routes/imports.ts create mode 100644 apps/vis/server/src/routes/logs.ts create mode 100644 apps/vis/server/src/routes/tasks.ts create mode 100644 apps/vis/server/test/lib/cron-store.test.ts create mode 100644 apps/vis/server/test/lib/import-store.test.ts create mode 100644 apps/vis/server/test/lib/log-reader.test.ts create mode 100644 apps/vis/server/test/lib/task-store.test.ts create mode 100644 apps/vis/server/test/routes/cron.test.ts create mode 100644 apps/vis/server/test/routes/imports.test.ts create mode 100644 apps/vis/server/test/routes/logs.test.ts create mode 100644 apps/vis/server/test/routes/tasks.test.ts create mode 100644 apps/vis/web/src/components/analysis/TimelineTab.tsx create mode 100644 apps/vis/web/src/components/logs/LogsTab.tsx create mode 100644 apps/vis/web/src/components/tasks/CronTab.tsx create mode 100644 apps/vis/web/src/components/tasks/TasksTab.tsx create mode 100644 apps/vis/web/src/hooks/useTasks.ts create mode 100644 apps/vis/web/src/lib/analysis.ts create mode 100644 apps/vis/web/test/analysis.test.ts create mode 100644 apps/vis/web/test/issues.test.ts create mode 100644 apps/vis/web/vitest.config.ts 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} +
+