import type { ProcessActorType, ProcessArtifact, ProcessEvent, ProcessRun, ProcessRunStatus, Session, } from '@/types'; import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core'; const TERMINAL_STATUSES = new Set(['done', 'error', 'cancelled']); const STALE_WAITING_MS = 2 * 60 * 1000; export type OfficeTaskStatus = ProcessRunStatus | 'blocked'; export type OfficeZoneId = | 'reception' | 'workspace' | 'collab' | 'research' | 'alert' | 'done'; export interface OfficeProgressView { mode: 'stage' | 'ratio' | 'status'; label: string; value: number | null; max: number | null; stageLabel: string | null; } export interface OfficeStatsView { totalRuns: number; activeRuns: number; doneRuns: number; errorRuns: number; cancelledRuns: number; memberCount: number; artifactCount: number; } export interface OfficeZoneView { id: OfficeZoneId; label: string; memberIds: string[]; taskIds: string[]; tone: 'neutral' | 'info' | 'warn' | 'danger' | 'success'; } export interface OfficeMemberView { memberId: string; actorId: string; actorName: string; actorType: ProcessActorType; status: OfficeTaskStatus; zoneId: OfficeZoneId; currentRunId: string; currentTitle: string; stageLabel: string | null; summary: string | null; startedAt: string | null; updatedAt: string | null; finishedAt: string | null; childRunIds: string[]; artifactCount: number; isPrimary: boolean; } export interface OfficeTaskView { taskId: string; runId: string; parentRunId: string | null; actorId: string; actorName: string; actorType: ProcessActorType; title: string; status: OfficeTaskStatus; stageLabel: string | null; summary: string | null; startedAt: string; updatedAt: string; finishedAt: string | null; childTaskIds: string[]; artifactCount: number; errorText: string | null; isRoot: boolean; } export interface OfficeAssignmentView { ownerRunId: string; ownerActorName: string; assigneeRunIds: string[]; assigneeActorNames: string[]; label: string; } export interface OfficeAlertView { id: string; level: 'info' | 'warn' | 'error'; title: string; description: string | null; runId: string | null; actorId: string | null; createdAt: string; } export interface OfficeView { officeId: string; taskId: string; sessionId: string | null; title: string; status: OfficeTaskStatus; createdAt: string; updatedAt: string; finishedAt: string | null; durationMs: number | null; sourceSessionLabel: string; rootRunId: string; rootActorName: string; currentStageLabel: string | null; progress: OfficeProgressView; stats: OfficeStatsView; alerts: OfficeAlertView[]; zones: OfficeZoneView[]; members: OfficeMemberView[]; tasks: OfficeTaskView[]; assignments: OfficeAssignmentView[]; detailRunIds: string[]; } export interface OfficeTaskListItem { officeId: string; taskId: string; sessionId: string | null; sessionLabel: string; title: string; status: OfficeTaskStatus; createdAt: string; updatedAt: string; finishedAt: string | null; rootRunId: string; rootActorName: string; memberCount: number; activeRuns: number; errorCount: number; artifactCount: number; currentStageLabel: string | null; progress: OfficeProgressView; } type BuildOfficeInput = { sessions: Session[]; processRuns: ProcessRun[]; processEvents: ProcessEvent[]; processArtifacts: ProcessArtifact[]; }; function toTime(value?: string | null): number | null { if (!value) return null; const parsed = new Date(value).getTime(); return Number.isFinite(parsed) ? parsed : null; } function compareIsoDesc(a?: string | null, b?: string | null): number { return (toTime(b) ?? 0) - (toTime(a) ?? 0); } function firstString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } function firstNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } function readMetadataString(metadata: Record | undefined, keys: string[]): string | null { for (const key of keys) { const value = firstString(metadata?.[key]); if (value) return value; } return null; } function readMetadataNumber(metadata: Record | undefined, keys: string[]): number | null { for (const key of keys) { const value = firstNumber(metadata?.[key]); if (value !== null) return value; } return null; } function latestTimestamp(values: Array): string | null { let selected: string | null = null; let selectedTime = -1; for (const value of values) { const time = toTime(value); if (time === null || time <= selectedTime) continue; selected = value ?? null; selectedTime = time; } return selected; } function getSessionLabel(sessions: Session[], sessionId: string | null, locale: AppLocale): string { if (!sessionId) return pickAppText(locale, '未关联会话', 'No session linked'); const session = sessions.find((item) => item.key === sessionId); if (!session) return sessionId; return session.path?.trim() || session.key; } function groupByRunId(items: T[]): Map { const map = new Map(); for (const item of items) { const collection = map.get(item.run_id); if (collection) { collection.push(item); continue; } map.set(item.run_id, [item]); } return map; } function buildChildrenMap(processRuns: ProcessRun[]): Map { const map = new Map(); for (const run of processRuns) { if (!run.parent_run_id) continue; const children = map.get(run.parent_run_id); if (children) { children.push(run); continue; } map.set(run.parent_run_id, [run]); } return map; } function findRootRuns(processRuns: ProcessRun[]): ProcessRun[] { const runIds = new Set(processRuns.map((run) => run.run_id)); return processRuns.filter((run) => !run.parent_run_id || !runIds.has(run.parent_run_id)); } function collectRunTree(rootRun: ProcessRun, childrenMap: Map): ProcessRun[] { const collected: ProcessRun[] = []; const stack = [rootRun]; const seen = new Set(); while (stack.length > 0) { const current = stack.pop(); if (!current || seen.has(current.run_id)) continue; seen.add(current.run_id); collected.push(current); const children = childrenMap.get(current.run_id) ?? []; for (let index = children.length - 1; index >= 0; index -= 1) { stack.push(children[index]); } } return collected; } function getRunUpdatedAt( run: ProcessRun, eventsByRun: Map, artifactsByRun: Map, ): string { const eventTimes = (eventsByRun.get(run.run_id) ?? []).map((event) => event.created_at); const artifactTimes = (artifactsByRun.get(run.run_id) ?? []).map((artifact) => artifact.created_at); return ( latestTimestamp([ ...eventTimes, ...artifactTimes, run.finished_at, run.started_at, ]) ?? run.started_at ); } function deriveStageLabel( run: ProcessRun, runEvents: ProcessEvent[], fallbackStatus: OfficeTaskStatus, locale: AppLocale, ): string | null { const runMetadataLabel = readMetadataString(run.metadata, [ 'stage_label', 'stage', 'phase_label', 'step_label', ]); if (runMetadataLabel) return runMetadataLabel; const sortedEvents = [...runEvents].sort((a, b) => compareIsoDesc(a.created_at, b.created_at)); for (const event of sortedEvents) { const label = readMetadataString(event.metadata, [ 'stage_label', 'stage', 'phase_label', 'step_label', ]); if (label) return label; } if (fallbackStatus === 'running') return pickAppText(locale, '执行中', 'Running'); if (fallbackStatus === 'waiting') return pickAppText(locale, '等待中', 'Waiting'); if (fallbackStatus === 'queued') return pickAppText(locale, '排队中', 'Queued'); if (fallbackStatus === 'done') return pickAppText(locale, '已完成', 'Done'); if (fallbackStatus === 'error') return pickAppText(locale, '失败', 'Error'); if (fallbackStatus === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled'); if (fallbackStatus === 'blocked') return pickAppText(locale, '阻塞', 'Blocked'); return null; } function deriveRunStatus( run: ProcessRun, updatedAt: string, now: number, ): OfficeTaskStatus { if (run.status !== 'waiting') return run.status; const updatedTime = toTime(updatedAt); if (updatedTime !== null && now - updatedTime > STALE_WAITING_MS) { return 'blocked'; } return 'waiting'; } function mapZoneId(status: OfficeTaskStatus, actorType: ProcessActorType): OfficeZoneId { if (status === 'queued') return 'reception'; if (status === 'waiting' || status === 'blocked') return actorType === 'mcp' ? 'research' : 'collab'; if (status === 'running') return actorType === 'mcp' ? 'research' : 'workspace'; if (status === 'done') return 'collab'; return 'alert'; } function zoneLabel(zoneId: OfficeZoneId, locale: AppLocale): string { if (zoneId === 'reception') return pickAppText(locale, '接待区', 'Reception'); if (zoneId === 'workspace') return pickAppText(locale, '工位区', 'Workspace'); if (zoneId === 'collab') return pickAppText(locale, '协作区', 'Collaboration'); if (zoneId === 'research') return pickAppText(locale, '研究区', 'Research'); if (zoneId === 'alert') return pickAppText(locale, '异常区', 'Alerts'); return pickAppText(locale, '完成区', 'Completed'); } function zoneTone(zoneId: OfficeZoneId): OfficeZoneView['tone'] { if (zoneId === 'workspace' || zoneId === 'research') return 'info'; if (zoneId === 'collab' || zoneId === 'reception') return 'warn'; if (zoneId === 'alert') return 'danger'; if (zoneId === 'done') return 'success'; return 'neutral'; } function taskStatusPriority(status: OfficeTaskStatus): number { if (status === 'running') return 6; if (status === 'blocked') return 5; if (status === 'waiting') return 4; if (status === 'queued') return 3; if (status === 'error') return 2; if (status === 'cancelled') return 1; return 0; } function selectDisplayRun( runs: ProcessRun[], eventsByRun: Map, artifactsByRun: Map, now: number, ): { run: ProcessRun; status: OfficeTaskStatus; updatedAt: string } { const sorted = [...runs] .map((run) => { const updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun); const status = deriveRunStatus(run, updatedAt, now); return { run, status, updatedAt }; }) .sort((a, b) => { const byStatus = taskStatusPriority(b.status) - taskStatusPriority(a.status); if (byStatus !== 0) return byStatus; return compareIsoDesc(a.updatedAt, b.updatedAt); }); return sorted[0]; } function deriveErrorText(run: ProcessRun, runEvents: ProcessEvent[], locale: AppLocale): string | null { if (run.status !== 'error') return null; const direct = firstString(run.summary); if (direct) return direct; const sortedEvents = [...runEvents].sort((a, b) => compareIsoDesc(a.created_at, b.created_at)); for (const event of sortedEvents) { if (event.status === 'error' && firstString(event.text)) { return event.text!.trim(); } } return pickAppText(locale, '任务执行失败', 'Task execution failed'); } function deriveProgress( rootRun: ProcessRun, taskRuns: ProcessRun[], taskViews: OfficeTaskView[], locale: AppLocale, ): OfficeProgressView { const stageValue = readMetadataNumber(rootRun.metadata, ['stage_index', 'step_index', 'phase_index']); const stageMax = readMetadataNumber(rootRun.metadata, ['stage_total', 'step_total', 'phase_total']); const stageLabel = readMetadataString(rootRun.metadata, ['stage_label', 'stage', 'phase_label', 'step_label']); if (stageValue !== null && stageMax !== null && stageMax > 0) { return { mode: 'ratio', label: pickAppText( locale, `阶段 ${Math.min(stageValue, stageMax)} / ${stageMax}`, `Stage ${Math.min(stageValue, stageMax)} / ${stageMax}` ), value: stageValue, max: stageMax, stageLabel, }; } const doneRuns = taskRuns.filter((run) => run.status === 'done').length; if (taskRuns.length > 0) { return { mode: 'ratio', label: pickAppText( locale, `已完成子任务 ${doneRuns} / ${taskRuns.length}`, `Subtasks completed ${doneRuns} / ${taskRuns.length}` ), value: doneRuns, max: taskRuns.length, stageLabel: stageLabel ?? taskViews.find((item) => item.isRoot)?.stageLabel ?? null, }; } return { mode: 'status', label: pickAppText(locale, '等待任务数据', 'Waiting for task data'), value: null, max: null, stageLabel, }; } function buildAlerts( taskViews: OfficeTaskView[], now: number, locale: AppLocale, ): OfficeAlertView[] { const alerts: OfficeAlertView[] = []; for (const task of taskViews) { if (task.status === 'error') { alerts.push({ id: `error:${task.runId}`, level: 'error', title: pickAppText(locale, `${task.actorName} 执行失败`, `${task.actorName} failed`), description: task.errorText, runId: task.runId, actorId: task.actorId, createdAt: task.updatedAt, }); } else if (task.status === 'blocked') { alerts.push({ id: `blocked:${task.runId}`, level: 'warn', title: pickAppText(locale, `${task.actorName} 长时间等待`, `${task.actorName} has been waiting for a while`), description: pickAppText(locale, '该任务长时间无更新,可能存在阻塞。', 'This task has not updated for a while and may be blocked.'), runId: task.runId, actorId: task.actorId, createdAt: task.updatedAt, }); } else if (task.status === 'waiting') { const updatedTime = toTime(task.updatedAt); if (updatedTime !== null && now - updatedTime > STALE_WAITING_MS) { alerts.push({ id: `stale:${task.runId}`, level: 'warn', title: pickAppText(locale, `${task.actorName} 等待时间偏长`, `${task.actorName} has been waiting longer than expected`), description: pickAppText(locale, '该任务仍处于等待态,建议查看详情确认依赖是否卡住。', 'This task is still waiting. Check the details to confirm whether a dependency is stuck.'), runId: task.runId, actorId: task.actorId, createdAt: task.updatedAt, }); } } } return alerts.sort((a, b) => compareIsoDesc(a.createdAt, b.createdAt)); } function buildZones(members: OfficeMemberView[], tasks: OfficeTaskView[], locale: AppLocale): OfficeZoneView[] { const ids: OfficeZoneId[] = ['reception', 'workspace', 'collab', 'research', 'alert', 'done']; return ids.map((id) => ({ id, label: zoneLabel(id, locale), memberIds: members.filter((member) => member.zoneId === id).map((member) => member.memberId), taskIds: tasks.filter((task) => mapZoneId(task.status, task.actorType) === id).map((task) => task.taskId), tone: zoneTone(id), })); } function buildAssignments(taskRuns: ProcessRun[], childrenMap: Map, locale: AppLocale): OfficeAssignmentView[] { return taskRuns .filter((run) => (childrenMap.get(run.run_id) ?? []).length > 0) .map((run) => { const children = childrenMap.get(run.run_id) ?? []; return { ownerRunId: run.run_id, ownerActorName: run.actor_name, assigneeRunIds: children.map((item) => item.run_id), assigneeActorNames: children.map((item) => item.actor_name), label: pickAppText(locale, `${run.actor_name} 分派了 ${children.length} 个子任务`, `${run.actor_name} assigned ${children.length} subtasks`), }; }); } export function isOfficeTaskTerminal(status: OfficeTaskStatus): boolean { return TERMINAL_STATUSES.has(status); } export function officeTaskStatusLabel(status: OfficeTaskStatus, locale: AppLocale = getCurrentAppLocale()): string { if (status === 'queued') return pickAppText(locale, '排队中', 'Queued'); if (status === 'running') return pickAppText(locale, '进行中', 'In Progress'); if (status === 'waiting') return pickAppText(locale, '等待中', 'Waiting'); if (status === 'blocked') return pickAppText(locale, '阻塞', 'Blocked'); if (status === 'done') return pickAppText(locale, '已完成', 'Done'); if (status === 'error') return pickAppText(locale, '失败', 'Error'); return pickAppText(locale, '已取消', 'Cancelled'); } export function buildOfficeView( taskId: string, input: BuildOfficeInput, locale: AppLocale = getCurrentAppLocale(), ): OfficeView | null { const { sessions, processRuns, processEvents, processArtifacts } = input; const runById = new Map(processRuns.map((run) => [run.run_id, run])); const rootRun = runById.get(taskId); if (!rootRun) return null; const childrenMap = buildChildrenMap(processRuns); const taskRuns = collectRunTree(rootRun, childrenMap); const taskRunIds = new Set(taskRuns.map((run) => run.run_id)); const taskEvents = processEvents.filter((event) => taskRunIds.has(event.run_id)); const taskArtifacts = processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id)); const eventsByRun = groupByRunId(taskEvents); const artifactsByRun = groupByRunId(taskArtifacts); const now = Date.now(); const taskViews: OfficeTaskView[] = taskRuns .map((run) => { const runEvents = eventsByRun.get(run.run_id) ?? []; const updatedAt = getRunUpdatedAt(run, eventsByRun, artifactsByRun); const status = deriveRunStatus(run, updatedAt, now); const stageLabel = deriveStageLabel(run, runEvents, status, locale); const childTaskIds = (childrenMap.get(run.run_id) ?? []) .filter((child) => taskRunIds.has(child.run_id)) .map((child) => child.run_id); return { taskId: run.run_id, runId: run.run_id, parentRunId: run.parent_run_id ?? null, actorId: run.actor_id, actorName: run.actor_name, actorType: run.actor_type, title: run.title, status, stageLabel, summary: firstString(run.summary), startedAt: run.started_at, updatedAt, finishedAt: run.finished_at ?? null, childTaskIds, artifactCount: (artifactsByRun.get(run.run_id) ?? []).length, errorText: deriveErrorText(run, runEvents, locale), isRoot: run.run_id === rootRun.run_id, }; }) .sort((a, b) => { if (a.isRoot !== b.isRoot) return a.isRoot ? -1 : 1; if (isOfficeTaskTerminal(a.status) !== isOfficeTaskTerminal(b.status)) { return isOfficeTaskTerminal(a.status) ? 1 : -1; } return compareIsoDesc(a.updatedAt, b.updatedAt); }); const actorRuns = new Map(); for (const run of taskRuns) { const collection = actorRuns.get(run.actor_id); if (collection) { collection.push(run); continue; } actorRuns.set(run.actor_id, [run]); } const members: OfficeMemberView[] = Array.from(actorRuns.entries()) .map(([actorId, runs]) => { const display = selectDisplayRun(runs, eventsByRun, artifactsByRun, now); const currentRun = display.run; const currentTask = taskViews.find((task) => task.runId === currentRun.run_id); return { memberId: actorId, actorId, actorName: currentRun.actor_name, actorType: currentRun.actor_type, status: display.status, zoneId: mapZoneId(display.status, currentRun.actor_type), currentRunId: currentRun.run_id, currentTitle: currentRun.title, stageLabel: currentTask?.stageLabel ?? null, summary: currentTask?.summary ?? null, startedAt: currentRun.started_at ?? null, updatedAt: display.updatedAt, finishedAt: currentRun.finished_at ?? null, childRunIds: (childrenMap.get(currentRun.run_id) ?? []).map((child) => child.run_id), artifactCount: runs.reduce((count, run) => count + (artifactsByRun.get(run.run_id) ?? []).length, 0), isPrimary: currentRun.run_id === rootRun.run_id, }; }) .sort((a, b) => { if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; const byStatus = taskStatusPriority(b.status) - taskStatusPriority(a.status); if (byStatus !== 0) return byStatus; return compareIsoDesc(a.updatedAt, b.updatedAt); }); const sessionId = rootRun.session_id ?? taskRuns.find((run) => run.session_id)?.session_id ?? null; const updatedAt = latestTimestamp([ ...taskViews.map((task) => task.updatedAt), rootRun.finished_at, rootRun.started_at, ]) ?? rootRun.started_at; const derivedRootStatus = deriveRunStatus(rootRun, updatedAt, now); const alerts = buildAlerts(taskViews, now, locale); const progress = deriveProgress(rootRun, taskRuns, taskViews, locale); const sourceSessionLabel = getSessionLabel(sessions, sessionId, locale); const createdAt = rootRun.started_at; const finishedAt = rootRun.finished_at ?? null; const durationStart = toTime(createdAt); const durationEnd = toTime(finishedAt ?? updatedAt); const durationMs = durationStart !== null && durationEnd !== null && durationEnd >= durationStart ? durationEnd - durationStart : null; return { officeId: rootRun.run_id, taskId: rootRun.run_id, sessionId, title: rootRun.title || pickAppText(locale, `任务 ${rootRun.run_id.slice(0, 8)}`, `Task ${rootRun.run_id.slice(0, 8)}`), status: derivedRootStatus, createdAt, updatedAt, finishedAt, durationMs, sourceSessionLabel, rootRunId: rootRun.run_id, rootActorName: rootRun.actor_name, currentStageLabel: deriveStageLabel(rootRun, eventsByRun.get(rootRun.run_id) ?? [], derivedRootStatus, locale), progress, stats: { totalRuns: taskRuns.length, activeRuns: taskViews.filter((task) => !isOfficeTaskTerminal(task.status)).length, doneRuns: taskViews.filter((task) => task.status === 'done').length, errorRuns: taskViews.filter((task) => task.status === 'error').length, cancelledRuns: taskViews.filter((task) => task.status === 'cancelled').length, memberCount: members.length, artifactCount: taskArtifacts.length, }, alerts, zones: buildZones(members, taskViews, locale), members, tasks: taskViews, assignments: buildAssignments(taskRuns, childrenMap, locale), detailRunIds: taskViews.map((task) => task.runId), }; } export function buildOfficeTaskList( input: BuildOfficeInput & { sessionId?: string | null }, locale: AppLocale = getCurrentAppLocale(), ): OfficeTaskListItem[] { const rootRuns = findRootRuns(input.processRuns); const offices = rootRuns .map((rootRun) => buildOfficeView(rootRun.run_id, input, locale)) .filter((office): office is OfficeView => office !== null) .filter((office) => !input.sessionId || office.sessionId === input.sessionId); return offices .map((office) => ({ officeId: office.officeId, taskId: office.taskId, sessionId: office.sessionId, sessionLabel: office.sourceSessionLabel, title: office.title, status: office.status, createdAt: office.createdAt, updatedAt: office.updatedAt, finishedAt: office.finishedAt, rootRunId: office.rootRunId, rootActorName: office.rootActorName, memberCount: office.members.length, activeRuns: office.stats.activeRuns, errorCount: office.stats.errorRuns, artifactCount: office.stats.artifactCount, currentStageLabel: office.currentStageLabel, progress: office.progress, })) .sort((a, b) => { if (isOfficeTaskTerminal(a.status) !== isOfficeTaskTerminal(b.status)) { return isOfficeTaskTerminal(a.status) ? 1 : -1; } return compareIsoDesc(a.updatedAt, b.updatedAt); }); }