/
fleisar
/
agent-timetracker
Обзор
Документация
Войти
/
fleisar
/
agent-timetracker
Код
Запросы
0
Задачи
Вики
Пакеты
1
Релизы
2
CI/CD
Аналитика
Безопасность
main
src/domain/taskTime.ts
330 строк
10 KB
Matvey Kuznetsov
fix: address final review findings
02 июл 2026, 18:27
02 июл 2026, 18:27
fc16438
Код
Авторство
О чём код?
import type { TrackerConfig } from "../config/types.js"; import { trackerError } from "./errors.js"; import type { Task, TaskTimeReport, TaskTimeReportAgentSummary, WorkSession } from "./types.js"; import type { TrackerStore } from "../storage/types.js"; import { SystemClock, type Clock } from "../util/clock.js"; import { parseIsoTimestamp, secondsBetween } from "../util/time.js"; type MaybePromise<T> = T | Promise<T>; type TaskTimeEvaluationInput = { taskId: string; at: string; }; export interface TaskTimeDependencies { store: TrackerStore; config?: Pick<TrackerConfig, "agents">; clock?: Clock; evaluateTimeouts?: (input: TaskTimeEvaluationInput) => MaybePromise<unknown>; } export interface GetTaskTimeInput { task_id: string; at?: string; include_sessions?: boolean; } export interface ListTaskSessionsInput { task_id: string; agent_id?: string; status?: WorkSession["status"]; limit?: number; cursor?: string; } export interface ListTaskSessionsResult { sessions: WorkSession[]; next_cursor: string | null; } type TaskTimeGroup = { total_work_seconds_closed: number; running_session_seconds: number; active_sessions: WorkSession[]; closed_sessions: WorkSession[]; }; type SessionListCursorState = { version: 1; offset: number; signature: string; }; function ensureTaskExists(store: TrackerStore, taskId: string): Task { const task = store.getTask(taskId); if (task == null) { throw trackerError("task_not_found", `Task not found: ${taskId}`, { taskId }); } return task; } function validateKnownAgent(config: Pick<TrackerConfig, "agents"> | undefined, agentId: string): void { if (config == null) { return; } if (config.agents[agentId] == null) { throw trackerError("unknown_agent", `Unknown agent: ${agentId}`, { agent_id: agentId }); } } function validateAt(at: string): string { return parseIsoTimestamp(at).toISOString(); } function compareSessions(left: WorkSession, right: WorkSession): number { if (left.started_at < right.started_at) { return -1; } if (left.started_at > right.started_at) { return 1; } if (left.session_id < right.session_id) { return -1; } if (left.session_id > right.session_id) { return 1; } return 0; } function sortSessions(sessions: WorkSession[]): WorkSession[] { return [...sessions].sort(compareSessions); } function getReferenceAt(at: string | undefined, clock: Clock): string { return validateAt(at ?? clock.now()); } function runningSeconds(session: WorkSession, at: string): number { return Math.max(0, secondsBetween(session.started_at, at)); } function buildGroup(sessions: WorkSession[], at: string): TaskTimeGroup { const activeSessions = sortSessions(sessions.filter((session) => session.status === "active")); const closedSessions = sortSessions(sessions.filter((session) => session.status === "closed")); return { total_work_seconds_closed: closedSessions.reduce((total, session) => total + secondsBetween(session.started_at, session.ended_at ?? session.last_activity_at), 0), running_session_seconds: activeSessions.reduce((total, session) => total + runningSeconds(session, at), 0), active_sessions: activeSessions, closed_sessions: closedSessions }; } function getLastActivityAt(sessions: WorkSession[]): string | null { let lastActivityAt: string | null = null; for (const session of sessions) { if (lastActivityAt == null || session.last_activity_at > lastActivityAt) { lastActivityAt = session.last_activity_at; } } return lastActivityAt; } function buildAgentSummary(sessions: WorkSession[], at: string, idleTimeoutSeconds: number): TaskTimeReportAgentSummary { const group = buildGroup(sessions, at); const totalAgentWorkSeconds = group.total_work_seconds_closed; const totalWorkSecondsEffective = totalAgentWorkSeconds + group.running_session_seconds; return { total_work_seconds_closed: group.total_work_seconds_closed, total_agent_work_seconds: totalAgentWorkSeconds, running_session_seconds: group.running_session_seconds, total_work_seconds_effective: totalWorkSecondsEffective, active_session_ids: group.active_sessions.map((session) => session.session_id), active_sessions: group.active_sessions, last_activity_at: getLastActivityAt(sessions), idle_timeout_seconds: idleTimeoutSeconds }; } function encodeCursor(state: SessionListCursorState): string { return Buffer.from(JSON.stringify(state), "utf8").toString("base64url"); } function decodeCursor(cursor: string): SessionListCursorState { let raw: unknown; try { raw = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); } catch { throw trackerError("invalid_filter", "Invalid session cursor", { cursor }); } if ( typeof raw !== "object" || raw == null || (raw as SessionListCursorState).version !== 1 || !Number.isInteger((raw as SessionListCursorState).offset) || (raw as SessionListCursorState).offset < 0 || typeof (raw as SessionListCursorState).signature !== "string" ) { throw trackerError("invalid_filter", "Invalid session cursor", { cursor }); } return raw as SessionListCursorState; } function buildCursorSignature(input: ListTaskSessionsInput): string { return JSON.stringify({ task_id: input.task_id, agent_id: input.agent_id ?? null, status: input.status ?? null, limit: input.limit ?? null }); } function validateListFilters(input: ListTaskSessionsInput): void { if (input.task_id.trim().length === 0) { throw trackerError("invalid_filter", "Task id is required", { task_id: input.task_id }); } if (input.agent_id != null && input.agent_id.trim().length === 0) { throw trackerError("invalid_filter", "Agent id must not be empty", { agent_id: input.agent_id }); } if (input.status != null && input.status !== "active" && input.status !== "closed") { throw trackerError("invalid_filter", "Invalid session status filter", { status: input.status }); } if (input.limit != null && (!Number.isInteger(input.limit) || input.limit <= 0)) { throw trackerError("invalid_filter", "Limit must be a positive integer", { limit: input.limit }); } } function filterSessions(sessions: WorkSession[], input: ListTaskSessionsInput): WorkSession[] { return sessions.filter((session) => { if (input.agent_id != null && session.agent_id !== input.agent_id) { return false; } if (input.status != null && session.status !== input.status) { return false; } return true; }); } function paginateSessions(sessions: WorkSession[], input: ListTaskSessionsInput): ListTaskSessionsResult { const signature = buildCursorSignature(input); let offset = 0; if (input.cursor != null) { const state = decodeCursor(input.cursor); if (state.signature !== signature) { throw trackerError("invalid_filter", "Session cursor does not match the requested filters", { cursor: input.cursor }); } offset = state.offset; } if (offset > sessions.length) { offset = sessions.length; } const limit = input.limit ?? sessions.length - offset; const page = sessions.slice(offset, offset + limit); const nextOffset = offset + page.length; return { sessions: page, next_cursor: nextOffset < sessions.length ? encodeCursor({ version: 1, offset: nextOffset, signature }) : null }; } export async function getTaskTime(deps: TaskTimeDependencies, input: GetTaskTimeInput): Promise<TaskTimeReport> { const clock = deps.clock ?? new SystemClock(); const referenceAt = getReferenceAt(input.at, clock); if (deps.evaluateTimeouts != null) { await deps.evaluateTimeouts({ taskId: input.task_id, at: referenceAt }); } const task = ensureTaskExists(deps.store, input.task_id); const sessions = sortSessions(deps.store.listTaskSessions(input.task_id).filter((session) => session.task_id === input.task_id)); const group = buildGroup(sessions, referenceAt); const totalAgentWorkSeconds = group.total_work_seconds_closed; const totalWorkSecondsEffective = group.total_work_seconds_closed + group.running_session_seconds; const perAgentSessions = new Map<string, WorkSession[]>(); for (const session of sessions) { const list = perAgentSessions.get(session.agent_id); if (list == null) { perAgentSessions.set(session.agent_id, [session]); continue; } list.push(session); } const perAgent: Record<string, TaskTimeReportAgentSummary> = {}; for (const [agentId, agentSessions] of perAgentSessions.entries()) { perAgent[agentId] = buildAgentSummary(agentSessions, referenceAt, task.idle_timeout_seconds); } return { task_id: task.task_id, status: task.status, total_work_seconds_closed: group.total_work_seconds_closed, total_agent_work_seconds: totalAgentWorkSeconds, running_session_seconds: group.running_session_seconds, total_work_seconds_effective: totalWorkSecondsEffective, active_session_ids: group.active_sessions.map((session) => session.session_id), active_sessions: group.active_sessions, last_activity_at: getLastActivityAt(sessions) ?? task.last_activity_at ?? null, idle_timeout_seconds: task.idle_timeout_seconds, per_agent: Object.keys(perAgent).length > 0 ? perAgent : undefined }; } export async function listTaskSessions( deps: TaskTimeDependencies, input: ListTaskSessionsInput ): Promise<ListTaskSessionsResult> { validateListFilters(input); if (input.agent_id != null) { validateKnownAgent(deps.config, input.agent_id); } ensureTaskExists(deps.store, input.task_id); const sessions = sortSessions(deps.store.listTaskSessions(input.task_id).filter((session) => session.task_id === input.task_id)); const filteredSessions = filterSessions(sessions, input); return paginateSessions(filteredSessions, input); }