diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts index 23bec45b3..39da15b50 100644 --- a/apps/vis/server/src/lib/agent-record-types.ts +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -237,8 +237,17 @@ export interface ImportManifest { workspaceDir?: string; sessionLogPath?: string; globalLogPath?: string; + desktopLogPath?: string; + webLogPath?: string; + desktopVersion?: string; installSource?: string; - shellEnv?: unknown; + shellEnv?: { + term?: string; + termProgram?: string; + termProgramVersion?: string; + multiplexer?: string; + shell?: string; + }; } /** vis-side bookkeeping for one imported bundle, written to @@ -297,6 +306,7 @@ export interface AgentInfo { agentId: string; type: 'main' | 'sub' | 'independent'; parentAgentId: string | null; + profileName: string | null; homedir: string; wireExists: boolean; wireRecordCount: number; diff --git a/apps/vis/server/src/lib/import-store.ts b/apps/vis/server/src/lib/import-store.ts index 63371a7fe..dc399319e 100644 --- a/apps/vis/server/src/lib/import-store.ts +++ b/apps/vis/server/src/lib/import-store.ts @@ -133,11 +133,20 @@ async function readManifest(dir: string): Promise { } } -/** Declared string fields of {@link ImportManifest}. `shellEnv` is free-form. */ +/** Declared string fields of {@link ImportManifest}. */ const MANIFEST_STRING_FIELDS = [ 'sessionId', 'exportedAt', 'pythinkerCodeVersion', 'wireProtocolVersion', 'os', 'nodejsVersion', 'sessionFirstActivity', 'sessionLastActivity', 'title', - 'workspaceDir', 'sessionLogPath', 'globalLogPath', 'installSource', + 'workspaceDir', 'sessionLogPath', 'globalLogPath', 'desktopLogPath', + 'webLogPath', 'desktopVersion', 'installSource', +] as const; + +const SHELL_ENV_STRING_FIELDS = [ + 'term', + 'termProgram', + 'termProgramVersion', + 'multiplexer', + 'shell', ] as const; /** @@ -153,7 +162,15 @@ function sanitizeManifest(raw: unknown): ImportManifest | null { for (const field of MANIFEST_STRING_FIELDS) { if (typeof o[field] === 'string') m[field] = o[field]; } - if (o['shellEnv'] !== undefined) m['shellEnv'] = o['shellEnv']; + const shellEnv = o['shellEnv']; + if (typeof shellEnv === 'object' && shellEnv !== null && !Array.isArray(shellEnv)) { + const source = shellEnv as Record; + const sanitized: Record = {}; + for (const field of SHELL_ENV_STRING_FIELDS) { + if (typeof source[field] === 'string') sanitized[field] = source[field]; + } + m['shellEnv'] = sanitized; + } return m as ImportManifest; } diff --git a/apps/vis/server/src/lib/session-store.ts b/apps/vis/server/src/lib/session-store.ts index 96ff784b1..60ef03b31 100644 --- a/apps/vis/server/src/lib/session-store.ts +++ b/apps/vis/server/src/lib/session-store.ts @@ -22,23 +22,20 @@ export function isSafeAgentId(id: string): boolean { interface StateJson { createdAt?: string | number; updatedAt?: string | number; + cwd?: string; + workDir?: string; title?: string; isCustomTitle?: boolean; lastPrompt?: string; // 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. + // bundle may hold non-object entries like `{ "main": null }`), so inventory + // skips anything that is not a record. // // v2 writes the REAL parent / dynamic-workflow-item label under `labels` // (its top-level `parentAgentId` is a fixed 'main' placeholder for sub // agents); v1 wrote them top-level. Read labels first, top-level as // fallback — the same order the engine itself uses. - agents?: Record; + agents?: Record; custom?: Record; } @@ -90,7 +87,15 @@ export async function readSessionDetail(home: string, sessionId: string): Promis } if (state.custom?.['imported_from_pythinker_cli'] === true) return null; const agents = await inventoryAgents(sessionDir, state); - return { sessionId, sessionDir, workDir, state, agents, imported: false, importMeta: null }; + return { + sessionId, + sessionDir, + workDir: recoverWorkDir(state, workDir), + state, + agents, + imported: false, + importMeta: null, + }; } /** Detail for an imported bundle. Same readers as a local session, but the @@ -117,7 +122,15 @@ async function readImportedDetail(home: string, importId: string): Promise agentId: id, type: id === 'main' ? 'main' : 'independent', parentAgentId: null, + profileName: null, homedir: join(agentsDir, id), wireExists: readable, wireRecordCount: info.count, @@ -205,7 +219,7 @@ async function tryReadSummary( return { sessionId, sessionDir, - workDir, + workDir: recoverWorkDir(state, workDir), title: state.title ?? null, lastPrompt: state.lastPrompt ?? null, isCustomTitle: state.isCustomTitle ?? false, @@ -271,7 +285,8 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise compareAgentIds(a.agentId, b.agentId)); @@ -354,20 +374,26 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion: let protocolVersion: string | null = null; for await (const line of rl) { if (line.length === 0) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) continue; + const record = parsed as Record; + if (typeof record['type'] !== 'string') continue; if (protocolVersion === null) { - // Strict: the first non-empty line MUST be a well-formed - // `metadata` record. Otherwise the list-view health would say - // "ok" while the wire-reader rejects the file on open. - let parsed: { type?: unknown; protocol_version?: unknown }; - try { - parsed = JSON.parse(line) as typeof parsed; - } catch { - throw new Error(`wire metadata is not valid JSON at line 1`); - } - if (parsed.type !== 'metadata' || typeof parsed.protocol_version !== 'string') { - throw new Error(`wire is missing a metadata header on line 1`); + if (record['type'] !== 'metadata') { + protocolVersion = '1.4'; + } else { + const version = record['protocol_version']; + const createdAt = record['created_at']; + if (typeof version !== 'string' || typeof createdAt !== 'number') { + throw new TypeError('wire metadata is malformed'); + } + protocolVersion = version; } - protocolVersion = parsed.protocol_version; } count += 1; } @@ -377,6 +403,32 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion: return { count, protocolVersion }; } +function normalizeAgentType( + value: unknown, + agentId: string, +): AgentInfo['type'] { + if (value === 'main' || value === 'sub' || value === 'independent') return value; + return agentId === 'main' ? 'main' : 'sub'; +} + +function normalizeNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function recoverWorkDir(state: StateJson, preferred: string): string { + if (preferred.length > 0) return preferred; + if (typeof state.cwd === 'string' && state.cwd.length > 0) return state.cwd; + if (typeof state.workDir === 'string' && state.workDir.length > 0) return state.workDir; + const customCwd = state.custom?.['cwd']; + return typeof customCwd === 'string' && customCwd.length > 0 ? customCwd : ''; +} + function parseTs(input: string | number | undefined): number { if (typeof input === 'number') return Number.isFinite(input) ? input : 0; if (!input) return 0; diff --git a/apps/vis/server/src/lib/task-store.ts b/apps/vis/server/src/lib/task-store.ts index 9c4c62cb8..7f4fa049b 100644 --- a/apps/vis/server/src/lib/task-store.ts +++ b/apps/vis/server/src/lib/task-store.ts @@ -2,8 +2,8 @@ // // Read-only reader for background tasks, persisted by the engine under each // spawning agent's homedir at `/tasks/.json` -// (+ `tasks//output.log`) — NOT the session root. Callers pass the -// agent homedir (`/agents/`). +// (+ `tasks//output.log`). Main-agent reads may also receive the +// legacy session root as a fallback. // // The visualizer never writes these files; it mirrors the engine's on-disk // layout (`packages/agent-core-v2/src/agent/task/persist.ts`) for reading only: @@ -12,7 +12,7 @@ // - 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 { open, readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { @@ -50,19 +50,44 @@ function taskOutputFile(agentDir: string, taskId: string): string { */ export async function listBackgroundTasks( agentDir: string, + fallbackDir?: string, ): Promise { + const primary = await listBackgroundTasksAt(agentDir); + const out = [...primary.tasks]; + if (fallbackDir !== undefined) { + const fallback = await listBackgroundTasksAt(fallbackDir); + for (const task of fallback.tasks) { + if (!primary.reservedIds.has(task.keyId)) out.push(task); + } + } + // Newest first; tasks with no start time sort last. + out.sort((a, b) => (b.task.startedAt ?? 0) - (a.task.startedAt ?? 0)); + return out.map((entry) => entry.task); +} + +interface ListedTask { + keyId: string; + task: BackgroundTaskInfo; +} + +async function listBackgroundTasksAt( + agentDir: string, +): Promise<{ reservedIds: Set; tasks: ListedTask[] }> { const dir = tasksDirOf(agentDir); let entries: import('node:fs').Dirent[]; try { entries = await readdir(dir, { withFileTypes: true }); } catch { - return []; + return { reservedIds: new Set(), tasks: [] }; } - const out: BackgroundTaskInfo[] = []; + const reservedIds = new Set(); + const tasks: ListedTask[] = []; for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + if (!entry.name.endsWith('.json')) continue; const id = entry.name.slice(0, -'.json'.length); if (!VALID_TASK_ID.test(id)) continue; + reservedIds.add(id); + if (!entry.isFile()) continue; let parsed: unknown; try { parsed = JSON.parse(await readFile(join(dir, entry.name), 'utf8')); @@ -70,32 +95,44 @@ export async function listBackgroundTasks( 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; - } + const task = normalizePersistedTask(parsed); + if (task === undefined || task.taskId !== id) continue; + tasks.push({ keyId: id, task }); } - // Newest first; tasks with no start time sort last. - out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)); - return out; + return { reservedIds, tasks }; } -/** Byte size of a task's `output.log` (0 when absent or unreadable). */ -export async function taskOutputSizeBytes( +export interface TaskOutputMetadata { + exists: boolean; + size: number; +} + +/** Presence and byte size of a task's `output.log`. */ +export async function taskOutputMetadata( agentDir: string, taskId: string, -): Promise { + fallbackDir?: string, +): Promise { + const handle = await openTaskOutput(agentDir, taskId, fallbackDir); + if (handle === undefined) return { exists: false, size: 0 }; try { - return (await stat(taskOutputFile(agentDir, taskId))).size; + return { exists: true, size: (await handle.stat()).size }; } catch { - return 0; + return { exists: false, size: 0 }; + } finally { + await handle.close(); } } +/** Byte size of a task's `output.log` (0 when absent, empty, or unreadable). */ +export async function taskOutputSizeBytes( + agentDir: string, + taskId: string, + fallbackDir?: string, +): Promise { + return (await taskOutputMetadata(agentDir, taskId, fallbackDir)).size; +} + export interface TaskOutputWindow { /** Byte offset this window starts at (clamped to >= 0). */ offset: number; @@ -123,13 +160,12 @@ export async function readTaskOutput( taskId: string, offset: number, maxBytes: number, + fallbackDir?: string, ): 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 { + const handle = await openTaskOutput(agentDir, taskId, fallbackDir); + if (handle === undefined) { return { offset: start, nextOffset: start, size: 0, content: '', eof: true }; } try { @@ -150,83 +186,179 @@ export async function readTaskOutput( } } +async function openTaskOutput( + agentDir: string, + taskId: string, + fallbackDir?: string, +): Promise> | undefined> { + try { + return await open(taskOutputFile(agentDir, taskId), 'r'); + } catch (error) { + if (!isMissingPath(error) || fallbackDir === undefined) return undefined; + } + try { + return await open(taskOutputFile(fallbackDir, taskId), 'r'); + } catch { + return undefined; + } +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + // ── normalization (ported from agent-core-v2/agent/task/persist.ts) ──────── -type LegacyBackgroundTaskStatus = - | 'running' - | 'awaiting_approval' - | 'completed' - | 'failed' - | 'killed' - | 'lost'; - -interface LegacyPersistedTask { - readonly task_id: string; - readonly command: string; +type ReadablePersistedTask = Record; + +interface CurrentTaskBase { + readonly taskId: 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; + readonly status: BackgroundTaskStatus; + readonly detached: boolean; + readonly startedAt: number; + readonly endedAt: number | null; + readonly stopReason?: string; + readonly terminalNotificationSuppressed?: boolean; + readonly resumeReminded?: boolean; + readonly timeoutMs?: number; +} + +const CURRENT_TASK_STATUSES: ReadonlySet = new Set([ + 'running', + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]); + +function normalizePersistedTask(task: ReadablePersistedTask): BackgroundTaskInfo | undefined { + const current = isLegacyPersistedTask(task) ? legacyPersistedTaskToCurrent(task) : task; + return decodeCurrentPersistedTask(current); } -type DiskPersistedTask = BackgroundTaskInfo | LegacyPersistedTask; +function decodeCurrentPersistedTask(task: ReadablePersistedTask): BackgroundTaskInfo | undefined { + const base = decodeCurrentTaskBase(task); + if (base === undefined) return undefined; -function normalizePersistedTask(task: DiskPersistedTask): BackgroundTaskInfo { - if (isLegacyPersistedTask(task)) return legacyPersistedTaskToInfo(task); - return { ...task, detached: task.detached ?? true }; + switch (task['kind']) { + case 'process': + if ( + typeof task['command'] !== 'string' || + !isFiniteNumber(task['pid']) || + !isNullableFiniteNumber(task['exitCode']) + ) { + return undefined; + } + return { + ...base, + kind: 'process', + command: task['command'], + pid: task['pid'], + exitCode: task['exitCode'], + parentToolCallId: optionalString(task['parentToolCallId']), + }; + case 'agent': + return { + ...base, + kind: 'agent', + agentId: optionalString(task['agentId']), + subagentType: optionalString(task['subagentType']), + parentToolCallId: optionalString(task['parentToolCallId']), + model: optionalString(task['model']), + thinkingEffort: optionalString(task['thinkingEffort']), + }; + case 'question': + if (!isFiniteNumber(task['questionCount'])) return undefined; + return { + ...base, + kind: 'question', + questionCount: task['questionCount'], + toolCallId: optionalString(task['toolCallId']), + }; + default: + return undefined; + } +} + +function decodeCurrentTaskBase(task: ReadablePersistedTask): CurrentTaskBase | undefined { + if ( + typeof task['taskId'] !== 'string' || + !VALID_TASK_ID.test(task['taskId']) || + typeof task['description'] !== 'string' || + !isCurrentTaskStatus(task['status']) || + !isFiniteNumber(task['startedAt']) || + !isNullableFiniteNumber(task['endedAt']) + ) { + return undefined; + } + return { + taskId: task['taskId'], + description: task['description'], + status: task['status'], + detached: optionalBoolean(task['detached']) ?? true, + startedAt: task['startedAt'], + endedAt: task['endedAt'], + stopReason: optionalString(task['stopReason']), + terminalNotificationSuppressed: optionalBoolean(task['terminalNotificationSuppressed']), + resumeReminded: optionalBoolean(task['resumeReminded']), + timeoutMs: optionalNumber(task['timeoutMs']), + }; } -function legacyPersistedTaskToInfo(task: LegacyPersistedTask): BackgroundTaskInfo { - const status = legacyStatusToCurrent(task); - const base = { +function legacyPersistedTaskToCurrent( + task: ReadablePersistedTask & { readonly task_id: string }, +): ReadablePersistedTask { + const base: ReadablePersistedTask = { taskId: task.task_id, - description: task.description, - status, + description: task['description'], + status: legacyStatusToCurrent(task), 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, + startedAt: task['started_at'], + endedAt: task['ended_at'], + stopReason: optionalNonEmptyString(task['stop_reason']), + timeoutMs: optionalNumber(task['timeout_ms']), }; if (task.task_id.startsWith('agent-')) { return { ...base, kind: 'agent', - agentId: optionalNonEmptyString(task.agent_id), - subagentType: optionalNonEmptyString(task.subagent_type), + agentId: optionalNonEmptyString(task['agent_id']), + subagentType: optionalNonEmptyString(task['subagent_type']), }; } return { ...base, kind: 'process', - command: task.command, - pid: task.pid, - exitCode: task.exit_code, + 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 legacyStatusToCurrent(task: ReadablePersistedTask): unknown { + 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 { +function isReadablePersistedTask(obj: unknown): obj is ReadablePersistedTask { 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 isLegacyPersistedTask( + task: ReadablePersistedTask, +): task is ReadablePersistedTask & { readonly task_id: string } { + return typeof task['task_id'] === 'string'; } function isRecord(value: unknown): value is Record { @@ -238,3 +370,30 @@ function optionalNonEmptyString(value: unknown): string | undefined { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function optionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function optionalNumber(value: unknown): number | undefined { + return isFiniteNumber(value) ? value : undefined; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isNullableFiniteNumber(value: unknown): value is number | null { + return value === null || isFiniteNumber(value); +} + +function isCurrentTaskStatus(value: unknown): value is BackgroundTaskStatus { + return ( + typeof value === 'string' && + CURRENT_TASK_STATUSES.has(value as BackgroundTaskStatus) + ); +} diff --git a/apps/vis/server/src/lib/wire-reader.ts b/apps/vis/server/src/lib/wire-reader.ts index 5abe6748e..d64d75bbf 100644 --- a/apps/vis/server/src/lib/wire-reader.ts +++ b/apps/vis/server/src/lib/wire-reader.ts @@ -1,7 +1,10 @@ import { createReadStream } from 'node:fs'; +import { basename, dirname } from 'node:path'; import { createInterface } from 'node:readline'; import { + isNewerWireVersion, + migrateV1_4ToV1_5, migrateWireRecord, resolveWireMigrations, type WireMigration, @@ -37,6 +40,8 @@ function bestEffortMigrations(): readonly WireMigration[] { * - below-1.0 (or otherwise unrecognized-low) — `resolveWireMigrations` * throws, so records run through the 1.0-onwards best-effort chain and a * warning is added to `warnings[]` so the UI can surface the caveat; + * - no metadata header — treat the journal as v1.4 and apply the v1.4 → + * v1.5 migration in memory; * - at/above the current 1.5 (including future versions) — resolves to an * empty chain, so records are passed through unchanged, with no migration * and no warning. */ @@ -46,8 +51,10 @@ export async function readAgentWire(path: string): Promise { let lineNo = 0; let metadata: WireReadResult['metadata'] | null = null; let migrations: readonly WireMigration[] = []; + let newerWireVersion = false; const records: WireEntry[] = []; const warnings: string[] = []; + const agentId = basename(dirname(path)); for await (const line of rl) { lineNo += 1; @@ -64,44 +71,57 @@ export async function readAgentWire(path: string): Promise { continue; } if (metadata === null) { - if (parsed['type'] !== 'metadata') { - throw new Error(`Wire file missing metadata header at line ${lineNo}`); + if (parsed['type'] === 'metadata') { + const pv = parsed['protocol_version']; + const ca = parsed['created_at']; + if (typeof pv !== 'string' || typeof ca !== 'number') { + throw new TypeError(`Wire metadata malformed at line ${lineNo}`); + } + newerWireVersion = isNewerWireVersion(pv); + try { + migrations = resolveWireMigrations(pv); + } catch (error) { + warnings.push( + `unrecognised protocol_version "${pv}" — parsing as best-effort (${(error as Error).message})`, + ); + migrations = bestEffortMigrations(); + } + metadata = { protocolVersion: pv, createdAt: ca }; + continue; } - const pv = parsed['protocol_version']; - const ca = parsed['created_at']; - if (typeof pv !== 'string' || typeof ca !== 'number') { - throw new TypeError(`Wire metadata malformed at line ${lineNo}`); - } - try { - migrations = resolveWireMigrations(pv); - } catch (error) { - warnings.push( - `unrecognised protocol_version "${pv}" — parsing as best-effort (${(error as Error).message})`, - ); - migrations = bestEffortMigrations(); - } - metadata = { protocolVersion: pv, createdAt: ca }; - continue; + warnings.push( + `line ${lineNo}: missing metadata header — assuming protocol_version "${migrateV1_4ToV1_5.sourceVersion}"`, + ); + migrations = [migrateV1_4ToV1_5]; + metadata = { + protocolVersion: migrateV1_4ToV1_5.sourceVersion, + createdAt: 0, + }; } - const raw = parsed as Record; + const raw = parsed; let migrated: Record; try { migrated = migrations.length === 0 - ? (structuredClone(raw) as Record) + ? structuredClone(raw) : (migrateWireRecord( raw as Record & { type: string }, migrations, ) as Record); } catch (error) { - // A single record that won't migrate is not fatal — keep the raw - // payload so the UI can still render whatever fields it understands. warnings.push( `line ${lineNo}: migration failed (${(error as Error).message}); using raw record`, ); - migrated = structuredClone(raw) as Record; + migrated = structuredClone(raw); + } + const normalized = newerWireVersion + ? migrated + : normalizePlanRevisionRecord(migrated, agentId); + if (normalized === undefined) { + warnings.push(`line ${lineNo}: invalid legacy plan.revision record skipped`); + continue; } - records.push({ lineNo, data: migrated as AgentRecord, raw }); + records.push({ lineNo, data: normalized as AgentRecord, raw }); } if (metadata === null) { throw new Error('Wire file is empty (no metadata)'); @@ -109,6 +129,37 @@ export async function readAgentWire(path: string): Promise { return { metadata, records, warnings }; } +function normalizePlanRevisionRecord( + record: Record, + agentId: string, +): Record | undefined { + if (record['type'] !== 'plan.revision' || 'key' in record) return record; + const legacyPath = record['path']; + if (typeof legacyPath !== 'string') return undefined; + const key = extractLegacyPlanRevisionKey(legacyPath, agentId); + if (key === undefined) return undefined; + const { path: _path, ...rest } = record; + return { ...rest, key }; +} + +function extractLegacyPlanRevisionKey(path: string, agentId: string): string | undefined { + if (path.includes('\\')) return undefined; + const segments = path.split('/'); + if ( + segments.length < 8 || + segments[0] !== 'sessions' || + segments[3] !== 'agents' || + segments[4] !== agentId || + segments + .slice(1, 3) + .some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + return undefined; + } + const key = segments.slice(5).join('/'); + return /^plan\/[^/]+\/v[0-9]+\.md$/.test(key) ? key : undefined; +} + function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } diff --git a/apps/vis/server/src/routes/tasks.ts b/apps/vis/server/src/routes/tasks.ts index 8f51f6dc4..2efc50ae4 100644 --- a/apps/vis/server/src/routes/tasks.ts +++ b/apps/vis/server/src/routes/tasks.ts @@ -7,7 +7,7 @@ import { isSafeTaskId, listBackgroundTasks, readTaskOutput, - taskOutputSizeBytes, + taskOutputMetadata, } from '../lib/task-store'; /** Default output-log window size: 256 KiB. Large enough to show a whole @@ -19,9 +19,9 @@ const MAX_OUTPUT_LIMIT = 4 * 1024 * 1024; export function tasksRoute(home: string = PYTHINKER_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. + // List background tasks (process / agent / question) for a session. Current + // tasks live under each spawning agent's homedir; the main agent also falls + // back to the legacy session-root tasks directory. r.get('/:id/tasks', async (c) => { const id = c.req.param('id'); const detail = await readSessionDetail(home, id); @@ -30,10 +30,16 @@ export function tasksRoute(home: string = PYTHINKER_CODE_HOME): Hono { } const entries: BackgroundTaskEntry[] = []; for (const agent of detail.agents) { - const tasks = await listBackgroundTasks(agent.homedir); + const fallbackDir = agent.agentId === 'main' ? detail.sessionDir : undefined; + const tasks = await listBackgroundTasks(agent.homedir, fallbackDir); for (const task of tasks) { - const outputSizeBytes = await taskOutputSizeBytes(agent.homedir, task.taskId); - entries.push({ task, agentId: agent.agentId, outputSizeBytes, outputExists: outputSizeBytes > 0 }); + const output = await taskOutputMetadata(agent.homedir, task.taskId, fallbackDir); + entries.push({ + task, + agentId: agent.agentId, + outputSizeBytes: output.size, + outputExists: output.exists, + }); } } // Newest first across all agents. @@ -58,17 +64,24 @@ export function tasksRoute(home: string = PYTHINKER_CODE_HOME): Hono { 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. + // Prefer the agent whose log exists, including an empty log. An explicit + // ?agent= short-circuits the scan. The main agent also reads the legacy + // session-root tasks directory as its fallback. 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 hintedAgent = detail.agents.find((agent) => agent.agentId === hinted); + let owner = hintedAgent ?? detail.agents[0]; + if (hintedAgent === undefined) { + for (const agent of detail.agents) { + const fallbackDir = agent.agentId === 'main' ? detail.sessionDir : undefined; + if ((await taskOutputMetadata(agent.homedir, taskId, fallbackDir)).exists) { + owner = agent; + break; + } } } - const window = await readTaskOutput(dir, taskId, offset, limit); + const dir = owner?.homedir ?? detail.sessionDir; + const fallbackDir = owner?.agentId === 'main' ? detail.sessionDir : undefined; + const window = await readTaskOutput(dir, taskId, offset, limit, fallbackDir); return c.json({ sessionId: id, taskId, diff --git a/apps/vis/server/test/lib/agent-tree.test.ts b/apps/vis/server/test/lib/agent-tree.test.ts index f8350adcc..1d86d6910 100644 --- a/apps/vis/server/test/lib/agent-tree.test.ts +++ b/apps/vis/server/test/lib/agent-tree.test.ts @@ -11,6 +11,7 @@ function info(overrides: Partial & Pick): Agent wireRecordCount: 0, wireProtocolVersion: '1.1', dynamicWorkflowItem: null, + profileName: null, ...overrides, }; } @@ -60,7 +61,7 @@ describe('agent-tree', () => { it('orders agents by numeric suffix, main first (agent-2 before agent-10)', () => { const mk = (id: string): AgentInfo => ({ agentId: id, type: id === 'main' ? 'main' : 'sub', parentAgentId: id === 'main' ? null : 'main', - homedir: '', wireExists: true, wireRecordCount: 0, wireProtocolVersion: null, dynamicWorkflowItem: null, + homedir: '', wireExists: true, wireRecordCount: 0, wireProtocolVersion: null, dynamicWorkflowItem: null, profileName: null, }); const tree = buildAgentTree([mk('main'), mk('agent-10'), mk('agent-2')]); const order = [tree[0]!.agentId, ...tree[0]!.children.map((c) => c.agentId)]; diff --git a/apps/vis/server/test/lib/import-store.test.ts b/apps/vis/server/test/lib/import-store.test.ts index 8eb580ab8..1a7158816 100644 --- a/apps/vis/server/test/lib/import-store.test.ts +++ b/apps/vis/server/test/lib/import-store.test.ts @@ -29,7 +29,16 @@ const WIRE = `${META_LINE}\n`; function validBundle(): Record { return { - 'manifest.json': JSON.stringify({ sessionId: 'session_orig', pythinkerCodeVersion: '0.20.2', workspaceDir: '/home/u/proj', title: 'imported demo' }), + 'manifest.json': JSON.stringify({ + sessionId: 'session_orig', + pythinkerCodeVersion: '0.20.2', + workspaceDir: '/home/u/proj', + title: 'imported demo', + desktopLogPath: 'logs/pythinker-desktop.log', + webLogPath: 'logs/pythinker-web.jsonl', + desktopVersion: '1.2.3', + shellEnv: { shell: '/bin/zsh', term: 'xterm-256color', ignored: 42 }, + }), '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/pythinker-code.log': '2026-06-01T00:00:00.000Z INFO hello k=v\n', @@ -49,6 +58,10 @@ describe('import-store', () => { expect(meta.originalName).toBe('demo.zip'); expect(meta.manifest?.sessionId).toBe('session_orig'); expect(meta.manifest?.workspaceDir).toBe('/home/u/proj'); + expect(meta.manifest?.desktopLogPath).toBe('logs/pythinker-desktop.log'); + expect(meta.manifest?.webLogPath).toBe('logs/pythinker-web.jsonl'); + expect(meta.manifest?.desktopVersion).toBe('1.2.3'); + expect(meta.manifest?.shellEnv).toEqual({ shell: '/bin/zsh', term: 'xterm-256color' }); // Extracted to imported// with the session shape intact. const dir = join(home, 'imported', meta.importId); diff --git a/apps/vis/server/test/lib/session-store.test.ts b/apps/vis/server/test/lib/session-store.test.ts index d8a2ad197..32f32e849 100644 --- a/apps/vis/server/test/lib/session-store.test.ts +++ b/apps/vis/server/test/lib/session-store.test.ts @@ -94,21 +94,50 @@ describe('session-store', () => { expect(sessions[0]!.mainWireRecordCount).toBe(0); }); - it('marks a session broken_main_wire when the wire metadata header is malformed', async () => { + it('treats a headerless v1.4 wire as recoverable', async () => { const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; const { writeFile } = await import('node:fs/promises'); const { join } = await import('node:path'); const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); - // First line is not a `metadata` record — list health used to stay - // 'ok' while readAgentWire would fail on open. await writeFile( wirePath, '{"type":"config.update","cwd":"/x","time":1}\n', ); const sessions = await listSessions(home); expect(sessions).toHaveLength(1); + expect(sessions[0]!.health).toBe('ok'); + expect(sessions[0]!.wireProtocolVersion).toBe('1.4'); + }); + + it('skips untyped JSON before valid wire metadata', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + await writeFile( + join(sessionDir, 'agents', 'main', 'wire.jsonl'), + '{}\n{"type":"metadata","protocol_version":"1.5","created_at":1}\n', + ); + + const sessions = await listSessions(home); + + expect(sessions[0]!.health).toBe('ok'); + expect(sessions[0]!.wireProtocolVersion).toBe('1.5'); + expect(sessions[0]!.mainWireRecordCount).toBe(1); + }); + + it('marks a session broken_main_wire when its wire has no typed records', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + await writeFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), '{}\n{}\n'); + + const sessions = await listSessions(home); + expect(sessions[0]!.health).toBe('broken_main_wire'); + expect(sessions[0]!.mainWireRecordCount).toBe(0); }); it('rejects unsafe agent ids', () => { @@ -311,7 +340,11 @@ describe('session-store', () => { state.agents['agent-1'] = { type: 'sub', parentAgentId: 'main', - labels: { parentAgentId: 'agent-0', dynamicWorkflowItem: 'batch item' }, + labels: { + parentAgentId: 'agent-0', + dynamicWorkflowItem: 'batch item', + profileName: 'explore', + }, }; await writeFile(statePath, JSON.stringify(state)); @@ -320,11 +353,67 @@ describe('session-store', () => { const nested = d!.agents.find((a) => a.agentId === 'agent-1')!; expect(nested.parentAgentId).toBe('agent-0'); expect(nested.dynamicWorkflowItem).toBe('batch item'); + expect(nested.profileName).toBe('explore'); // agent-0 has no labels — the top-level v1 fields still apply. const flat = d!.agents.find((a) => a.agentId === 'agent-0')!; expect(flat.parentAgentId).toBe('main'); }); + it('recovers the workDir from v2 state when the append index is unavailable', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { readFile, rm, writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const statePath = join(sessionDir, 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf8')); + state.cwd = '/workspace/from-state'; + await writeFile(statePath, JSON.stringify(state)); + await rm(join(home, 'session_index.jsonl')); + + const [summary] = await listSessions(home); + const detail = await readSessionDetail(home, 'session_fixture'); + + expect(summary!.workDir).toBe('/workspace/from-state'); + expect(detail!.workDir).toBe('/workspace/from-state'); + }); + + it('normalizes untrusted agent metadata before exposing it', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const { readFile, writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const statePath = join(sessionDir, 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf8')); + state.agents.main.type = {}; + state.agents.main.labels = { + parentAgentId: {}, + profileName: {}, + dynamicWorkflowItem: {}, + }; + state.agents['agent-0'].type = 'invalid'; + state.agents['agent-0'].parentAgentId = []; + state.agents['agent-0'].dynamicWorkflowItem = 42; + state.agents['agent-0'].labels = { profileName: ' ' }; + await writeFile(statePath, JSON.stringify(state)); + + const d = await readSessionDetail(home, 'session_fixture'); + + const main = d!.agents.find((a) => a.agentId === 'main')!; + expect(main).toMatchObject({ + type: 'main', + parentAgentId: null, + profileName: null, + dynamicWorkflowItem: null, + }); + const subagent = d!.agents.find((a) => a.agentId === 'agent-0')!; + expect(subagent).toMatchObject({ + type: 'sub', + parentAgentId: null, + profileName: null, + dynamicWorkflowItem: null, + }); + }); + it('reads the legacy session-meta/state.json path when state.json is missing', async () => { const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; diff --git a/apps/vis/server/test/lib/task-store.test.ts b/apps/vis/server/test/lib/task-store.test.ts index a4537fa4a..86a393d1d 100644 --- a/apps/vis/server/test/lib/task-store.test.ts +++ b/apps/vis/server/test/lib/task-store.test.ts @@ -8,6 +8,7 @@ import { isSafeTaskId, listBackgroundTasks, readTaskOutput, + taskOutputMetadata, taskOutputSizeBytes, } from '../../src/lib/task-store'; @@ -28,17 +29,21 @@ describe('task-store', () => { 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, + detached: true, startedAt: 1000, endedAt: 2000, stopReason: 'finished', + terminalNotificationSuppressed: true, resumeReminded: false, timeoutMs: 60_000, + parentToolCallId: 'tool-process', }); 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, + parentToolCallId: 'tool-agent', model: 'test-model', + thinkingEffort: 'high', }); await writeTask(sessionDir, 'question-cccccccc.json', { taskId: 'question-cccccccc', kind: 'question', description: 'ask user', questionCount: 2, status: 'running', detached: false, - startedAt: 2500, endedAt: null, + startedAt: 2500, endedAt: null, toolCallId: 'tool-question', }); const tasks = await listBackgroundTasks(sessionDir); @@ -48,9 +53,140 @@ describe('task-store', () => { 'bash-aaaaaaaa', // 1000 ]); const proc = tasks.find((t) => t.kind === 'process'); - expect(proc).toMatchObject({ command: 'pnpm build', pid: 4242, exitCode: 0 }); + expect(proc).toMatchObject({ + command: 'pnpm build', + pid: 4242, + exitCode: 0, + stopReason: 'finished', + terminalNotificationSuppressed: true, + resumeReminded: false, + timeoutMs: 60_000, + parentToolCallId: 'tool-process', + }); + const agent = tasks.find((t) => t.kind === 'agent'); + expect(agent).toMatchObject({ + agentId: 'agent-1', + subagentType: 'Explore', + parentToolCallId: 'tool-agent', + model: 'test-model', + thinkingEffort: 'high', + }); const question = tasks.find((t) => t.kind === 'question'); - expect(question).toMatchObject({ questionCount: 2, detached: false }); + expect(question).toMatchObject({ + questionCount: 2, + toolCallId: 'tool-question', + detached: false, + }); + }); + + it('sanitizes type-corrupt optional fields on every current task kind', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + + await writeTask(sessionDir, 'bash-aaaaaaaa.json', { + taskId: 'bash-aaaaaaaa', kind: 'process', description: 'process', + command: 'true', pid: 1, exitCode: null, status: 'running', + detached: {}, startedAt: 100, endedAt: null, stopReason: {}, + terminalNotificationSuppressed: 'yes', resumeReminded: [], timeoutMs: '1000', + parentToolCallId: {}, + }); + await writeTask(sessionDir, 'agent-bbbbbbbb.json', { + taskId: 'agent-bbbbbbbb', kind: 'agent', description: 'agent', + status: 'failed', startedAt: 200, endedAt: 300, + agentId: {}, subagentType: [], parentToolCallId: 1, model: {}, + thinkingEffort: false, + }); + await writeTask(sessionDir, 'question-cccccccc.json', { + taskId: 'question-cccccccc', kind: 'question', description: 'question', + questionCount: 2, status: 'completed', startedAt: 300, endedAt: 400, + toolCallId: {}, + }); + + const tasks = await listBackgroundTasks(sessionDir); + expect(tasks).toHaveLength(3); + + const proc = tasks.find((task) => task.kind === 'process')!; + expect(proc.detached).toBe(true); + expect(proc.stopReason).toBeUndefined(); + expect(proc.terminalNotificationSuppressed).toBeUndefined(); + expect(proc.resumeReminded).toBeUndefined(); + expect(proc.timeoutMs).toBeUndefined(); + expect(proc.parentToolCallId).toBeUndefined(); + + const agent = tasks.find((task) => task.kind === 'agent')!; + expect(agent.agentId).toBeUndefined(); + expect(agent.subagentType).toBeUndefined(); + expect(agent.parentToolCallId).toBeUndefined(); + expect(agent.model).toBeUndefined(); + expect(agent.thinkingEffort).toBeUndefined(); + + const question = tasks.find((task) => task.kind === 'question')!; + expect(question.toolCallId).toBeUndefined(); + for (const task of tasks) { + expect(Object.values(task).some((value) => value !== null && typeof value === 'object')) + .toBe(false); + } + }); + + it('skips current tasks with invalid discriminants or required fields', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + + const agent = { + taskId: 'agent-00000000', kind: 'agent', description: 'valid', + status: 'running', startedAt: 100, endedAt: null, + }; + const corrupt = [ + { ...agent, taskId: 'invalid' }, + { ...agent, kind: 'unknown' }, + { ...agent, description: {} }, + { ...agent, status: 'awaiting_approval' }, + { ...agent, startedAt: '100' }, + { ...agent, endedAt: {} }, + { ...agent, kind: 'process', command: {}, pid: 1, exitCode: null }, + { ...agent, kind: 'process', command: 'true', pid: '1', exitCode: null }, + { ...agent, kind: 'process', command: 'true', pid: 1, exitCode: '0' }, + { ...agent, kind: 'question', questionCount: '1' }, + ]; + for (const [index, task] of corrupt.entries()) { + await writeTask(sessionDir, `task-0000000${index}.json`, task); + } + await writeTask(sessionDir, 'agent-ffffffff.json', { + ...agent, + taskId: 'agent-ffffffff', + }); + + expect((await listBackgroundTasks(sessionDir)).map((task) => task.taskId)).toEqual([ + 'agent-ffffffff', + ]); + }); + + it('skips task ids that disagree with their file key and keeps primary shadowing', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const mainDir = join(sessionDir, 'agents', 'main'); + + await writeTask(mainDir, 'bash-aaaaaaaa.json', { + taskId: 'bash-bbbbbbbb', kind: 'process', description: 'current mismatch', + command: 'true', pid: 1, exitCode: 0, status: 'completed', + startedAt: 100, endedAt: 200, + }); + await writeTask(mainDir, 'agent-cccccccc.json', { + task_id: 'agent-dddddddd', command: '', description: 'legacy mismatch', + pid: 1, started_at: 100, ended_at: 200, exit_code: 0, status: 'completed', + }); + await writeTask(sessionDir, 'bash-eeeeeeee.json', { + taskId: 'bash-eeeeeeee', kind: 'process', description: 'fallback shadowed', + command: 'true', pid: 2, exitCode: 0, status: 'completed', + startedAt: 100, endedAt: 200, + }); + await writeTask(mainDir, 'bash-eeeeeeee.json', { + taskId: 'bash-ffffffff', kind: 'process', description: 'primary mismatch', + command: 'true', pid: 3, exitCode: 0, status: 'completed', + startedAt: 100, endedAt: 200, + }); + + expect(await listBackgroundTasks(mainDir, sessionDir)).toEqual([]); }); it('normalizes legacy snake_case tasks to the current shape', async () => { @@ -118,6 +254,33 @@ describe('task-store', () => { expect(await listBackgroundTasks(sessionDir)).toEqual([]); }); + it('falls back to session-root tasks for main and lets primary keys shadow fallback', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const mainDir = join(sessionDir, 'agents', 'main'); + + await writeTask(sessionDir, 'bash-aaaaaaaa.json', { + taskId: 'bash-aaaaaaaa', kind: 'process', description: 'fallback shadowed', + command: 'fallback', pid: 1, exitCode: 0, status: 'completed', + detached: true, startedAt: 100, endedAt: 200, + }); + await writeTask(sessionDir, 'bash-bbbbbbbb.json', { + taskId: 'bash-bbbbbbbb', kind: 'process', description: 'fallback visible', + command: 'fallback', pid: 2, exitCode: 0, status: 'completed', + detached: true, startedAt: 200, endedAt: 300, + }); + await mkdir(join(mainDir, 'tasks'), { recursive: true }); + await writeFile(join(mainDir, 'tasks', 'bash-aaaaaaaa.json'), '{ broken'); + await writeTask(mainDir, 'bash-cccccccc.json', { + taskId: 'bash-cccccccc', kind: 'process', description: 'primary visible', + command: 'primary', pid: 3, exitCode: 0, status: 'completed', + detached: true, startedAt: 300, endedAt: 400, + }); + + const tasks = await listBackgroundTasks(mainDir, sessionDir); + expect(tasks.map((task) => task.taskId)).toEqual(['bash-cccccccc', 'bash-bbbbbbbb']); + }); + it('reads output.log byte windows with size + eof', async () => { const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; @@ -145,6 +308,39 @@ describe('task-store', () => { expect(w).toMatchObject({ size: 0, content: '', eof: true }); }); + it('falls back to session-root output and treats an empty primary log as present', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const mainDir = join(sessionDir, 'agents', 'main'); + const fallbackOutputDir = join(sessionDir, 'tasks', 'bash-12345678'); + await mkdir(fallbackOutputDir, { recursive: true }); + await writeFile(join(fallbackOutputDir, 'output.log'), 'legacy output'); + + expect(await taskOutputMetadata(mainDir, 'bash-12345678', sessionDir)).toEqual({ + exists: true, + size: 13, + }); + expect(await readTaskOutput(mainDir, 'bash-12345678', 0, 100, sessionDir)).toMatchObject({ + size: 13, + content: 'legacy output', + eof: true, + }); + + const primaryOutputDir = join(mainDir, 'tasks', 'bash-12345678'); + await mkdir(primaryOutputDir, { recursive: true }); + await writeFile(join(primaryOutputDir, 'output.log'), ''); + + expect(await taskOutputMetadata(mainDir, 'bash-12345678', sessionDir)).toEqual({ + exists: true, + size: 0, + }); + expect(await readTaskOutput(mainDir, 'bash-12345678', 0, 100, sessionDir)).toMatchObject({ + size: 0, + content: '', + eof: true, + }); + }); + it('isSafeTaskId guards traversal', () => { expect(isSafeTaskId('bash-1a2b3c4d')).toBe(true); expect(isSafeTaskId('agent-deadbeef')).toBe(true); diff --git a/apps/vis/server/test/lib/wire-reader.test.ts b/apps/vis/server/test/lib/wire-reader.test.ts index a7dca4cea..d13593679 100644 --- a/apps/vis/server/test/lib/wire-reader.test.ts +++ b/apps/vis/server/test/lib/wire-reader.test.ts @@ -128,6 +128,67 @@ describe('wire-reader', () => { } }); + it('recovers a headerless journal with the same v1.4 assumption as core-v2', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const path = join(sessionDir, 'agents', 'main', 'wire.jsonl'); + await writeFile( + path, + JSON.stringify({ + type: 'goal.create', + agentId: 'main', + goalId: 'goal-1', + objective: 'ship', + time: 40, + }) + '\n', + ); + + const result = await readAgentWire(path); + + expect(result.metadata).toEqual({ protocolVersion: '1.4', createdAt: 0 }); + expect(result.warnings).toEqual([ + 'line 1: missing metadata header — assuming protocol_version "1.4"', + ]); + expect(result.records[0]).toMatchObject({ + lineNo: 1, + data: { type: 'goal.create', wallClockResumedAt: 40 }, + }); + }); + + it('normalizes legacy plan revision paths to the current storage key', async () => { + const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const path = join(sessionDir, 'agents', 'main', 'wire.jsonl'); + await writeFile( + path, + [ + JSON.stringify({ type: 'metadata', protocol_version: '1.5', created_at: 1 }), + JSON.stringify({ + type: 'plan.revision', + agentId: 'main', + id: 'demo-plan', + version: 2, + path: 'sessions/workspace/session_demo/agents/main/plan/demo-plan/v2.md', + sha256: 'abc', + bytes: 10, + time: 2, + }), + ].join('\n') + '\n', + ); + + const result = await readAgentWire(path); + + expect(result.records[0]!.data).toMatchObject({ + type: 'plan.revision', + key: 'plan/demo-plan/v2.md', + }); + expect(result.records[0]!.data).not.toHaveProperty('path'); + expect(result.records[0]!.raw).toHaveProperty( + 'path', + 'sessions/workspace/session_demo/agents/main/plan/demo-plan/v2.md', + ); + }); + it('collects warnings for malformed body lines', async () => { const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; diff --git a/apps/vis/server/test/routes/tasks.test.ts b/apps/vis/server/test/routes/tasks.test.ts index b760b662c..c237c8277 100644 --- a/apps/vis/server/test/routes/tasks.test.ts +++ b/apps/vis/server/test/routes/tasks.test.ts @@ -68,6 +68,24 @@ describe('tasks route', () => { expect(body.nextOffset).toBe(8); }); + it('GET output falls back to the legacy session-root task log', async () => { + const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); + cleanup = c; + const dir = join(sessionDir, 'tasks', 'bash-87654321'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'output.log'), 'legacy output'); + + const res = await tasksRoute(home).request( + '/session_fixture/tasks/bash-87654321/output?offset=0&limit=100', + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + size: 13, + content: 'legacy output', + eof: true, + }); + }); + it('GET output returns empty window for a task with no log', async () => { const { home, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; diff --git a/apps/vis/web/src/components/analysis/TimelineTab.tsx b/apps/vis/web/src/components/analysis/TimelineTab.tsx index 3c609c092..66a08b395 100644 --- a/apps/vis/web/src/components/analysis/TimelineTab.tsx +++ b/apps/vis/web/src/components/analysis/TimelineTab.tsx @@ -54,7 +54,7 @@ export function TimelineTab({ sessionId }: TimelineTabProps) { {agents.length === 0 ? : null} {agents.map((a) => ( ))} diff --git a/apps/vis/web/src/components/context/ContextTab.tsx b/apps/vis/web/src/components/context/ContextTab.tsx index a4873d4da..92d6ebe7b 100644 --- a/apps/vis/web/src/components/context/ContextTab.tsx +++ b/apps/vis/web/src/components/context/ContextTab.tsx @@ -28,9 +28,16 @@ export function ContextTab({ sessionId, initialAgentId = 'main' }: ContextTabPro const agents = detail?.agents ?? []; const messages = ctx?.messages ?? []; - const session = ctx?.usage.byScope.session ?? EMPTY_USAGE; + const sessionUsage = ctx?.usage.byScope.session ?? EMPTY_USAGE; + const turnUsage = ctx?.usage.byScope.turn ?? EMPTY_USAGE; + const cumulativeUsage: TokenUsage = { + inputOther: sessionUsage.inputOther + turnUsage.inputOther, + output: sessionUsage.output + turnUsage.output, + inputCacheRead: sessionUsage.inputCacheRead + turnUsage.inputCacheRead, + inputCacheCreation: sessionUsage.inputCacheCreation + turnUsage.inputCacheCreation, + }; // Live context-window fill (latest step.end usage), distinct from the - // cumulative `session` spend the 4-segment bar breaks down. + // cumulative session-scoped + turn-scoped spend the bar breaks down. const contextTokens = ctx?.contextTokens ?? 0; const config = ctx?.config ?? {}; const permissionMode = ctx?.permission.mode ?? null; @@ -55,6 +62,7 @@ export function ContextTab({ sessionId, initialAgentId = 'main' }: ContextTabPro {agents.map((a) => ( ))} @@ -130,8 +138,8 @@ export function ContextTab({ sessionId, initialAgentId = 'main' }: ContextTabPro ) : null} {/* Live context-window fill (contextTokens) + the 4-segment cumulative - session-usage breakdown. */} - + session-scoped and turn-scoped usage breakdown. */} + {/* Message stream */}
diff --git a/apps/vis/web/src/components/state/StateTab.tsx b/apps/vis/web/src/components/state/StateTab.tsx index 49f0f7950..8e4fe0aa4 100644 --- a/apps/vis/web/src/components/state/StateTab.tsx +++ b/apps/vis/web/src/components/state/StateTab.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import type { ImportInfo } from '../../types'; -import { formatAbsoluteTime, formatRelativeTime } from '../../util/time'; +import { formatAbsoluteTime, formatRelativeTime, parseTimestamp } from '../../util/time'; import { CopyButton } from '../shared/CopyButton'; import { JsonViewer } from '../shared/JsonViewer'; import { Pill } from '../shared/Pill'; @@ -16,8 +16,8 @@ interface StateJsonShape { isCustomTitle?: boolean; lastPrompt?: string; forkedFrom?: string; - createdAt?: string; - updatedAt?: string; + createdAt?: string | number; + updatedAt?: string | number; agents?: Record; custom?: Record & { imported_from_pythinker_cli?: boolean }; } @@ -32,8 +32,8 @@ export function StateTab({ state, importMeta }: StateTabProps) { return (state ?? {}) as StateJsonShape; }, [state]); - const createdMs = parseIso(s.createdAt); - const updatedMs = parseIso(s.updatedAt); + const createdMs = parseTimestamp(s.createdAt); + const updatedMs = parseTimestamp(s.updatedAt); const agentIds = s.agents !== undefined ? Object.keys(s.agents) : []; const importedFromPythinkerCli = s.custom?.imported_from_pythinker_cli === true; @@ -203,7 +203,7 @@ function ManifestCard({ meta }: { meta: ImportInfo }) { ); } -function TsValue({ ms, raw }: { ms: number | null; raw: string | undefined }) { +function TsValue({ ms, raw }: { ms: number | null; raw: string | number | undefined }) { if (ms === null) { return raw !== undefined && raw !== '' ? ( {raw} @@ -222,9 +222,3 @@ function TsValue({ ms, raw }: { ms: number | null; raw: string | undefined }) { ); } - -function parseIso(input: string | undefined): number | null { - if (input === undefined || input === '') return null; - const n = Date.parse(input); - return Number.isFinite(n) ? n : null; -} diff --git a/apps/vis/web/src/components/subagents/SubagentNode.tsx b/apps/vis/web/src/components/subagents/SubagentNode.tsx index 47df0ba6f..27ef75f53 100644 --- a/apps/vis/web/src/components/subagents/SubagentNode.tsx +++ b/apps/vis/web/src/components/subagents/SubagentNode.tsx @@ -33,6 +33,11 @@ export function SubagentNode({ node, sessionId }: Props) { {node.type} {node.agentId} + {node.profileName ? ( + + {node.profileName} + + ) : null} {node.dynamicWorkflowItem ? ( {node.dynamicWorkflowItem} diff --git a/apps/vis/web/src/components/wire/WireTab.tsx b/apps/vis/web/src/components/wire/WireTab.tsx index 8b8c06d71..f7fefa9b1 100644 --- a/apps/vis/web/src/components/wire/WireTab.tsx +++ b/apps/vis/web/src/components/wire/WireTab.tsx @@ -186,6 +186,7 @@ export function WireTab({ sessionId, initialAgentId = 'main' }: WireTabProps) { {agents.map((a) => ( ))} diff --git a/apps/vis/web/src/pages/SessionDetailPage.tsx b/apps/vis/web/src/pages/SessionDetailPage.tsx index 7aa26d5b4..036ccd343 100644 --- a/apps/vis/web/src/pages/SessionDetailPage.tsx +++ b/apps/vis/web/src/pages/SessionDetailPage.tsx @@ -15,7 +15,7 @@ import { WireTab } from '../components/wire/WireTab'; import { Pill } from '../components/shared/Pill'; import { useSession } from '../hooks/useSession'; import { useCron, useTasks } from '../hooks/useTasks'; -import { formatAbsoluteTime, formatRelativeTime } from '../util/time'; +import { formatAbsoluteTime, formatRelativeTime, parseTimestamp } from '../util/time'; type TabId = 'wire' | 'timeline' | 'context' | 'agents' | 'tasks' | 'cron' | 'logs' | 'state'; @@ -42,8 +42,9 @@ export function SessionDetailPage() { const state = (session.state ?? null) as { title?: string; lastPrompt?: string; - updatedAt?: string; + updatedAt?: string | number; } | null; + const updatedAt = parseTimestamp(state?.updatedAt); const mainAgent = session.agents.find((a) => a.agentId === 'main') ?? null; const subagentCount = session.agents.filter((a) => a.agentId !== 'main').length; @@ -80,10 +81,9 @@ export function SessionDetailPage() {
) : null}
- {state?.updatedAt ? ( + {updatedAt !== null ? ( - updated {formatRelativeTime(Date.parse(state.updatedAt))} ·{' '} - {formatAbsoluteTime(Date.parse(state.updatedAt))} + updated {formatRelativeTime(updatedAt)} · {formatAbsoluteTime(updatedAt)} ) : null} {session.workDir ? ( diff --git a/apps/vis/web/src/pages/SubagentDetailPage.tsx b/apps/vis/web/src/pages/SubagentDetailPage.tsx index 37f9a82eb..fc86e60c1 100644 --- a/apps/vis/web/src/pages/SubagentDetailPage.tsx +++ b/apps/vis/web/src/pages/SubagentDetailPage.tsx @@ -59,6 +59,11 @@ export function SubagentDetailPage() { {agent.type} + {agent.profileName ? ( + + {agent.profileName} + + ) : null} {agent.parentAgentId !== null ? ( parent ·{' '} diff --git a/apps/vis/web/src/util/time.ts b/apps/vis/web/src/util/time.ts index f6e1349ac..e0a9212bd 100644 --- a/apps/vis/web/src/util/time.ts +++ b/apps/vis/web/src/util/time.ts @@ -1,3 +1,10 @@ +export function parseTimestamp(value: string | number | undefined): number | null { + if (value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + /** Format an epoch-ms timestamp as a short relative string ("2m ago", "3h ago"). */ export function formatRelativeTime(epochMs: number): string { if (!epochMs || !Number.isFinite(epochMs)) return '—'; diff --git a/apps/vis/web/test/time.test.ts b/apps/vis/web/test/time.test.ts new file mode 100644 index 000000000..6590bcd5b --- /dev/null +++ b/apps/vis/web/test/time.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { parseTimestamp } from '../src/util/time'; + +describe('parseTimestamp', () => { + it('accepts current epoch milliseconds and legacy ISO timestamps', () => { + expect(parseTimestamp(1_784_012_345_678)).toBe(1_784_012_345_678); + expect(parseTimestamp('2026-07-14T01:25:45.678Z')).toBe(1_783_992_345_678); + expect(parseTimestamp('invalid')).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ae406b13b..418502067 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,11 +10,17 @@ export default defineConfig({ 'apps/pythinker-code', 'apps/desktop', 'apps/pythinker-web', + 'apps/vis/server', + 'apps/vis/web', ...vscodeProjects, ], coverage: { provider: 'v8', - include: ['packages/*/src/**/*.ts', 'apps/*/src/**/*.ts'], + include: [ + 'packages/*/src/**/*.ts', + 'apps/*/src/**/*.ts', + 'apps/vis/*/src/**/*.{ts,tsx}', + ], exclude: ['**/*.test.ts', '**/*.spec.ts', '**/dist/**'], reporter: ['text', 'html'], },