kin/web/frontend/src/api.ts
2026-03-17 20:32:49 +02:00

419 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<never> {
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<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`)
if (!res.ok) await throwApiError(res)
return res.json()
}
async function patch<T>(path: string, body: unknown): Promise<T> {
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<T>(path: string, body: unknown): Promise<T> {
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<T>(path: string): Promise<T> {
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<T>(path: string, body: FormData): Promise<T> {
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<string, unknown> | null
spec: Record<string, unknown> | 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<string, unknown> | null
}
export interface PendingAction {
type: string
description: string
original_item: Record<string, unknown>
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<Project[]>('/projects'),
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
task: (id: string) => get<Task>(`/tasks/${id}`),
taskFull: (id: string) => get<TaskFull>(`/tasks/${id}/full`),
taskPipeline: (id: string) => get<PipelineStep[]>(`/tasks/${id}/pipeline`),
cost: (days = 7) => get<CostEntry[]>(`/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<Project>('/projects', data),
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string; category?: string; acceptance_criteria?: string }) =>
post<Task>('/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<AuditResult>(`/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<Task>(`/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<Project>(`/projects/${id}`, data),
deployProject: (projectId: string) =>
post<DeployResult>(`/projects/${projectId}/deploy`, {}),
syncObsidian: (projectId: string) =>
post<ObsidianSyncResult>(`/projects/${projectId}/sync/obsidian`, {}),
deleteProject: (id: string) =>
del<void>(`/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<Decision>('/decisions', data),
newProject: (data: NewProjectPayload) =>
post<NewProjectResult>('/projects/new', data),
getPhases: (projectId: string) =>
get<Phase[]>(`/projects/${projectId}/phases`),
notifications: (projectId?: string) =>
get<EscalationNotification[]>(`/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<Phase>(`/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<ProjectEnvironment[]>(`/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<ProjectEnvironment & { scan_task_id?: string }>(`/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<ProjectEnvironment & { scan_task_id?: string }>(`/projects/${projectId}/environments/${envId}`, data),
deleteEnvironment: (projectId: string, envId: number) =>
del<void>(`/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<ChatMessage[]>(`/projects/${projectId}/chat?limit=${limit}`),
sendChatMessage: (projectId: string, content: string) =>
post<ChatSendResult>(`/projects/${projectId}/chat`, { content }),
uploadAttachment: (taskId: string, file: File) => {
const fd = new FormData()
fd.append('file', file)
return postForm<Attachment>(`/tasks/${taskId}/attachments`, fd)
},
getAttachments: (taskId: string) =>
get<Attachment[]>(`/tasks/${taskId}/attachments`),
deleteAttachment: (taskId: string, id: number) =>
del<void>(`/tasks/${taskId}/attachments/${id}`),
attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`,
getPipelineLogs: (pipelineId: string, sinceId: number) =>
get<PipelineLog[]>(`/pipelines/${pipelineId}/logs?since_id=${sinceId}`),
projectLinks: (projectId: string) =>
get<ProjectLink[]>(`/projects/${projectId}/links`),
createProjectLink: (data: { from_project: string; to_project: string; type: string; description?: string }) =>
post<ProjectLink>('/project-links', data),
deleteProjectLink: (id: number) =>
del<void>(`/project-links/${id}`),
}