const BASE = '/api' export class ApiError extends Error { code: string constructor(code: string, message: string) { super(message) this.name = 'ApiError' this.code = code } } async function throwApiError(res: Response): Promise { let code = '' let msg = `${res.status} ${res.statusText}` try { const data = await res.json() if (data.error) code = data.error if (data.message) msg = data.message } catch {} throw new ApiError(code, msg) } async function get(path: string): Promise { const res = await fetch(`${BASE}${path}`) if (!res.ok) await throwApiError(res) return res.json() } async function patch(path: string, body: unknown): Promise { const res = await fetch(`${BASE}${path}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) await throwApiError(res) return res.json() } async function post(path: string, body: unknown): Promise { const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) await throwApiError(res) return res.json() } async function del(path: string): Promise { const res = await fetch(`${BASE}${path}`, { method: 'DELETE' }) if (!res.ok) await throwApiError(res) if (res.status === 204) return undefined as T return res.json() } async function postForm(path: string, body: FormData): Promise { const res = await fetch(`${BASE}${path}`, { method: 'POST', body }) if (!res.ok) await throwApiError(res) return res.json() } export interface Project { id: string name: string path: string status: string priority: number tech_stack: string[] | null execution_mode: string | null autocommit_enabled: number | null auto_test_enabled: number | null worktrees_enabled: number | null obsidian_vault_path: string | null deploy_command: string | null test_command: string | null deploy_host: string | null deploy_path: string | null deploy_runtime: string | null deploy_restart_cmd: string | null created_at: string total_tasks: number done_tasks: number active_tasks: number blocked_tasks: number review_tasks: number project_type: string | null ssh_host: string | null ssh_user: string | null ssh_key_path: string | null ssh_proxy_jump: string | null description: string | null } export interface ObsidianSyncResult { exported_decisions: number tasks_updated: number errors: string[] vault_path: string } export interface ProjectDetail extends Project { tasks: Task[] modules: Module[] decisions: Decision[] } export interface Task { id: string project_id: string title: string status: string priority: number assigned_role: string | null parent_task_id: string | null brief: Record | null spec: Record | null execution_mode: string | null blocked_reason: string | null dangerously_skipped: number | null category: string | null acceptance_criteria: string | null feedback?: string | null created_at: string updated_at: string } export interface Decision { id: number project_id: string task_id: string | null type: string category: string | null title: string description: string tags: string[] | null created_at: string } export interface Module { id: number project_id: string name: string type: string path: string description: string | null owner_role: string | null dependencies: string[] | null } export interface PipelineStep { id: number agent_role: string action: string output_summary: string | null success: boolean | number duration_seconds: number | null tokens_used: number | null model: string | null cost_usd: number | null created_at: string } export interface DeployStepResult { step: string command: string stdout: string stderr: string exit_code: number } export interface DeployResult { success: boolean exit_code: number stdout: string stderr: string duration_seconds: number steps?: string[] results?: DeployStepResult[] // WARNING (decision #546): бэкенд возвращает list[str] (project_id), не объекты dependents_deployed?: string[] overall_success?: boolean } export interface ProjectLink { id: number from_project: string to_project: string // WARNING (decision #527): поле называется `type` — так отдаёт бэкенд. // НЕ переименовывать в link_type — это вызовет runtime undefined во всех компонентах. type: string description: string | null created_at: string } export interface TaskFull extends Task { pipeline_steps: PipelineStep[] related_decisions: Decision[] project_deploy_command: string | null project_deploy_host: string | null project_deploy_path: string | null project_deploy_runtime: string | null pipeline_id: string | null } export interface PipelineLog { id: number ts: string level: 'INFO' | 'DEBUG' | 'ERROR' | 'WARN' message: string extra_json: Record | null } export interface PendingAction { type: string description: string original_item: Record options: string[] } export interface CostEntry { project_id: string project_name: string runs: number total_tokens: number total_cost_usd: number total_duration_seconds: number } export interface Phase { id: number project_id: string role: string phase_order: number status: string task_id: string | null revise_count: number revise_comment: string | null created_at: string updated_at: string task?: Task | null } export interface NewProjectPayload { id: string name: string path: string description: string roles: string[] tech_stack?: string[] priority?: number language?: string project_type?: string ssh_host?: string ssh_user?: string ssh_key_path?: string ssh_proxy_jump?: string } export interface NewProjectResult { project: Project phases: Phase[] } export interface AuditItem { id: string reason: string } export interface AuditResult { success: boolean already_done: AuditItem[] still_pending: AuditItem[] unclear: AuditItem[] duration_seconds?: number cost_usd?: number error?: string } export interface ProjectEnvironment { id: number project_id: string name: string host: string port: number username: string auth_type: string is_installed: number created_at: string updated_at: string } export interface EscalationNotification { task_id: string project_id: string agent_role: string reason: string pipeline_step: string | null blocked_at: string telegram_sent: boolean } export interface ChatMessage { id: number project_id: string role: 'user' | 'assistant' content: string message_type: string task_id: string | null created_at: string task_stub?: { id: string title: string status: string } | null } export interface ChatSendResult { user_message: ChatMessage assistant_message: ChatMessage task?: Task | null } export interface Attachment { id: number task_id: string filename: string mime_type: string size: number created_at: string } export const api = { projects: () => get('/projects'), project: (id: string) => get(`/projects/${id}`), task: (id: string) => get(`/tasks/${id}`), taskFull: (id: string) => get(`/tasks/${id}/full`), taskPipeline: (id: string) => get(`/tasks/${id}/pipeline`), cost: (days = 7) => get(`/cost?days=${days}`), createProject: (data: { id: string; name: string; path?: string; tech_stack?: string[]; priority?: number; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) => post('/projects', data), createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string; category?: string; acceptance_criteria?: string }) => post('/tasks', data), approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string; create_followups?: boolean }) => post<{ status: string; followup_tasks: Task[]; needs_decision: boolean; pending_actions: PendingAction[] }>(`/tasks/${id}/approve`, data || {}), resolveAction: (id: string, action: PendingAction, choice: string) => post<{ choice: string; result: unknown }>(`/tasks/${id}/resolve`, { action, choice }), rejectTask: (id: string, reason: string) => post<{ status: string }>(`/tasks/${id}/reject`, { reason }), reviseTask: (id: string, comment: string) => post<{ status: string; comment: string }>(`/tasks/${id}/revise`, { comment }), runTask: (id: string) => post<{ status: string }>(`/tasks/${id}/run`, {}), bootstrap: (data: { path: string; id: string; name: string }) => post<{ project: Project }>('/bootstrap', data), auditProject: (projectId: string) => post(`/projects/${projectId}/audit`, {}), auditApply: (projectId: string, taskIds: string[]) => post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }), patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string; acceptance_criteria?: string }) => patch(`/tasks/${id}`, data), patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; auto_test_enabled?: boolean; worktrees_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; test_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string; deploy_host?: string; deploy_path?: string; deploy_runtime?: string; deploy_restart_cmd?: string }) => patch(`/projects/${id}`, data), deployProject: (projectId: string) => post(`/projects/${projectId}/deploy`, {}), syncObsidian: (projectId: string) => post(`/projects/${projectId}/sync/obsidian`, {}), deleteProject: (id: string) => del(`/projects/${id}`), deleteDecision: (projectId: string, decisionId: number) => del<{ deleted: number }>(`/projects/${projectId}/decisions/${decisionId}`), createDecision: (data: { project_id: string; type: string; title: string; description: string; category?: string; tags?: string[] }) => post('/decisions', data), newProject: (data: NewProjectPayload) => post('/projects/new', data), getPhases: (projectId: string) => get(`/projects/${projectId}/phases`), notifications: (projectId?: string) => get(`/notifications${projectId ? `?project_id=${projectId}` : ''}`), approvePhase: (phaseId: number, comment?: string) => post<{ phase: Phase; next_phase: Phase | null }>(`/phases/${phaseId}/approve`, { comment }), rejectPhase: (phaseId: number, reason: string) => post(`/phases/${phaseId}/reject`, { reason }), revisePhase: (phaseId: number, comment: string) => post<{ phase: Phase; new_task: Task }>(`/phases/${phaseId}/revise`, { comment }), startPhase: (projectId: string) => post<{ status: string; phase_id: number; task_id: string }>(`/projects/${projectId}/phases/start`, {}), environments: (projectId: string) => get(`/projects/${projectId}/environments`), createEnvironment: (projectId: string, data: { name: string; host: string; port?: number; username: string; auth_type?: string; auth_value?: string; is_installed?: boolean }) => post(`/projects/${projectId}/environments`, data), updateEnvironment: (projectId: string, envId: number, data: { name?: string; host?: string; port?: number; username?: string; auth_type?: string; auth_value?: string; is_installed?: boolean }) => patch(`/projects/${projectId}/environments/${envId}`, data), deleteEnvironment: (projectId: string, envId: number) => del(`/projects/${projectId}/environments/${envId}`), scanEnvironment: (projectId: string, envId: number) => post<{ status: string; task_id: string }>(`/projects/${projectId}/environments/${envId}/scan`, {}), chatHistory: (projectId: string, limit = 50) => get(`/projects/${projectId}/chat?limit=${limit}`), sendChatMessage: (projectId: string, content: string) => post(`/projects/${projectId}/chat`, { content }), uploadAttachment: (taskId: string, file: File) => { const fd = new FormData() fd.append('file', file) return postForm(`/tasks/${taskId}/attachments`, fd) }, getAttachments: (taskId: string) => get(`/tasks/${taskId}/attachments`), deleteAttachment: (taskId: string, id: number) => del(`/tasks/${taskId}/attachments/${id}`), attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`, getPipelineLogs: (pipelineId: string, sinceId: number) => get(`/pipelines/${pipelineId}/logs?since_id=${sinceId}`), projectLinks: (projectId: string) => get(`/projects/${projectId}/links`), createProjectLink: (data: { from_project: string; to_project: string; type: string; description?: string }) => post('/project-links', data), deleteProjectLink: (id: number) => del(`/project-links/${id}`), }