kin: KIN-021 Аудит-лог для --dangerously-skip-permissions в auto mode
This commit is contained in:
parent
67071c757d
commit
a0b0976d8d
16 changed files with 1477 additions and 14 deletions
|
|
@ -27,6 +27,7 @@ vi.mock('../api', () => ({
|
|||
auditProject: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
patchProject: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -509,3 +510,115 @@ describe('KIN-047: TaskDetail — Approve/Reject в статусе review', () =
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// KIN-065: Autocommit toggle в ProjectView
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-065: ProjectView — Autocommit toggle', () => {
|
||||
it('Кнопка Autocommit присутствует в DOM', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit'))
|
||||
expect(btn?.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('Кнопка имеет title "Autocommit: off" когда autocommit_enabled=0', async () => {
|
||||
vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 0 } as any)
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit'))
|
||||
expect(btn?.attributes('title')).toBe('Autocommit: off')
|
||||
})
|
||||
|
||||
it('Кнопка имеет title "Autocommit: on..." когда autocommit_enabled=1', async () => {
|
||||
vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 1 } as any)
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit'))
|
||||
expect(btn?.attributes('title')).toContain('Autocommit: on')
|
||||
})
|
||||
|
||||
it('Клик по кнопке вызывает patchProject с autocommit_enabled=true (включение)', async () => {
|
||||
vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 0 } as any)
|
||||
vi.mocked(api.patchProject).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 1 } as any)
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit'))
|
||||
await btn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: true })
|
||||
})
|
||||
|
||||
it('Клик по включённой кнопке вызывает patchProject с autocommit_enabled=false (выключение)', async () => {
|
||||
vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 1 } as any)
|
||||
vi.mocked(api.patchProject).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 0 } as any)
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit'))
|
||||
await btn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: false })
|
||||
})
|
||||
|
||||
it('При ошибке patchProject состояние кнопки откатывается (rollback)', async () => {
|
||||
vi.mocked(api.project).mockResolvedValue({ ...MOCK_PROJECT, autocommit_enabled: 0 } as any)
|
||||
vi.mocked(api.patchProject).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit'))
|
||||
await btn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// После ошибки откат: кнопка снова отображает "off"
|
||||
const btnAfter = wrapper.findAll('button').find(b => b.text().includes('Autocommit'))
|
||||
expect(btnAfter?.attributes('title')).toBe('Autocommit: off')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export interface Task {
|
|||
spec: Record<string, unknown> | null
|
||||
execution_mode: string | null
|
||||
blocked_reason: string | null
|
||||
dangerously_skipped: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -168,10 +169,12 @@ export const api = {
|
|||
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 }) =>
|
||||
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string }) =>
|
||||
patch<Task>(`/tasks/${id}`, data),
|
||||
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean }) =>
|
||||
patch<Project>(`/projects/${id}`, data),
|
||||
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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,13 @@ const filteredTasks = computed(() => {
|
|||
return tasks
|
||||
})
|
||||
|
||||
const manualEscalationTasks = computed(() => {
|
||||
if (!project.value) return []
|
||||
return project.value.tasks.filter(
|
||||
t => t.brief?.task_type === 'manual_escalation' && t.status !== 'done' && t.status !== 'cancelled'
|
||||
)
|
||||
})
|
||||
|
||||
const filteredDecisions = computed(() => {
|
||||
if (!project.value) return []
|
||||
let decs = project.value.decisions
|
||||
|
|
@ -220,24 +227,30 @@ async function runTask(taskId: string, event: Event) {
|
|||
}
|
||||
}
|
||||
|
||||
async function patchTaskField(taskId: string, data: { priority?: number; route_type?: string }) {
|
||||
try {
|
||||
const updated = await api.patchTask(taskId, data)
|
||||
if (project.value) {
|
||||
const idx = project.value.tasks.findIndex(t => t.id === taskId)
|
||||
if (idx >= 0) project.value.tasks[idx] = updated
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function addDecision() {
|
||||
decFormError.value = ''
|
||||
try {
|
||||
const tags = decForm.value.tags ? decForm.value.tags.split(',').map(s => s.trim()).filter(Boolean) : undefined
|
||||
const body = {
|
||||
await api.createDecision({
|
||||
project_id: props.id,
|
||||
type: decForm.value.type,
|
||||
title: decForm.value.title,
|
||||
description: decForm.value.description,
|
||||
category: decForm.value.category || undefined,
|
||||
tags,
|
||||
}
|
||||
const res = await fetch('/api/decisions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed')
|
||||
showAddDecision.value = false
|
||||
decForm.value = { type: 'decision', title: '', description: '', category: '', tags: '' }
|
||||
await load()
|
||||
|
|
@ -328,6 +341,30 @@ async function addDecision() {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Manual escalation tasks -->
|
||||
<div v-if="manualEscalationTasks.length" class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-orange-400 uppercase tracking-wide">⚠ Требуют ручного решения</span>
|
||||
<span class="text-xs text-orange-600">({{ manualEscalationTasks.length }})</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<router-link v-for="t in manualEscalationTasks" :key="t.id"
|
||||
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
|
||||
class="flex items-center justify-between px-3 py-2 border border-orange-800/60 bg-orange-950/20 rounded text-sm hover:border-orange-600 no-underline block transition-colors">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
|
||||
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
|
||||
<span class="text-orange-300 truncate">{{ t.title }}</span>
|
||||
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">escalated from {{ t.parent_task_id }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
||||
<span v-if="t.brief?.description" class="text-orange-600 truncate max-w-[200px]">{{ t.brief.description }}</span>
|
||||
<span>pri {{ t.priority }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
||||
<div v-else class="space-y-1">
|
||||
<router-link v-for="t in filteredTasks" :key="t.id"
|
||||
|
|
@ -344,7 +381,26 @@ async function addDecision() {
|
|||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
||||
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>
|
||||
<span>pri {{ t.priority }}</span>
|
||||
<select
|
||||
@click.stop
|
||||
@change.stop="patchTaskField(t.id, { route_type: ($event.target as HTMLSelectElement).value })"
|
||||
:value="(t.brief as Record<string, string> | null)?.route_type || ''"
|
||||
class="bg-gray-900 border border-gray-700 rounded px-1 py-0.5 text-[10px] text-gray-500 cursor-pointer hover:border-gray-500 hover:text-gray-300 transition-colors"
|
||||
title="Task type">
|
||||
<option value="">—</option>
|
||||
<option value="debug">debug</option>
|
||||
<option value="feature">feature</option>
|
||||
<option value="refactor">refactor</option>
|
||||
<option value="hotfix">hotfix</option>
|
||||
</select>
|
||||
<select
|
||||
@click.stop
|
||||
@change.stop="patchTaskField(t.id, { priority: Number(($event.target as HTMLSelectElement).value) })"
|
||||
:value="t.priority"
|
||||
class="bg-gray-900 border border-gray-700 rounded px-1 py-0.5 text-[10px] text-gray-500 cursor-pointer hover:border-gray-500 hover:text-gray-300 transition-colors"
|
||||
title="Priority">
|
||||
<option v-for="n in 10" :key="n" :value="n">p{{ n }}</option>
|
||||
</select>
|
||||
<button v-if="t.status === 'pending'"
|
||||
@click="runTask(t.id, $event)"
|
||||
class="px-2 py-0.5 bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 text-[10px]"
|
||||
|
|
|
|||
|
|
@ -201,6 +201,23 @@ async function runPipeline() {
|
|||
|
||||
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||
const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||
const isManualEscalation = computed(() => task.value?.brief?.task_type === 'manual_escalation')
|
||||
|
||||
const resolvingManually = ref(false)
|
||||
|
||||
async function resolveManually() {
|
||||
if (!task.value) return
|
||||
if (!confirm('Пометить задачу как решённую вручную?')) return
|
||||
resolvingManually.value = true
|
||||
try {
|
||||
const updated = await api.patchTask(props.id, { status: 'done' })
|
||||
task.value = { ...task.value, ...updated }
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
resolvingManually.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
|
|
@ -228,6 +245,51 @@ async function changeStatus(newStatus: string) {
|
|||
statusChanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Edit modal (pending tasks only)
|
||||
const showEdit = ref(false)
|
||||
const editForm = ref({ title: '', briefText: '', priority: 5 })
|
||||
const editLoading = ref(false)
|
||||
const editError = ref('')
|
||||
|
||||
function getBriefText(brief: Record<string, unknown> | null): string {
|
||||
if (!brief) return ''
|
||||
if (typeof brief === 'string') return brief as string
|
||||
if ('text' in brief) return String(brief.text)
|
||||
return JSON.stringify(brief)
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (!task.value) return
|
||||
editForm.value = {
|
||||
title: task.value.title,
|
||||
briefText: getBriefText(task.value.brief),
|
||||
priority: task.value.priority,
|
||||
}
|
||||
editError.value = ''
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!task.value) return
|
||||
editLoading.value = true
|
||||
editError.value = ''
|
||||
try {
|
||||
const data: Parameters<typeof api.patchTask>[1] = {}
|
||||
if (editForm.value.title !== task.value.title) data.title = editForm.value.title
|
||||
if (editForm.value.priority !== task.value.priority) data.priority = editForm.value.priority
|
||||
const origBriefText = getBriefText(task.value.brief)
|
||||
if (editForm.value.briefText !== origBriefText) data.brief_text = editForm.value.briefText
|
||||
if (Object.keys(data).length === 0) { showEdit.value = false; return }
|
||||
const updated = await api.patchTask(props.id, data)
|
||||
task.value = { ...task.value, ...updated }
|
||||
showEdit.value = false
|
||||
} catch (e: any) {
|
||||
editError.value = e.message
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -264,7 +326,32 @@ async function changeStatus(newStatus: string) {
|
|||
<span v-if="isRunning" class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
|
||||
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
||||
</div>
|
||||
<div v-if="task.brief" class="text-xs text-gray-500 mb-1">
|
||||
<!-- Manual escalation context banner -->
|
||||
<div v-if="isManualEscalation" class="mb-3 px-3 py-2 border border-orange-800/60 bg-orange-950/20 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-semibold text-orange-400">⚠ Требует ручного решения</span>
|
||||
<span v-if="task.parent_task_id" class="text-xs text-gray-600">
|
||||
— эскалация из
|
||||
<router-link :to="`/task/${task.parent_task_id}`" class="text-orange-600 hover:text-orange-400">
|
||||
{{ task.parent_task_id }}
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-orange-300">{{ task.title }}</p>
|
||||
<p v-if="task.brief?.description" class="text-xs text-gray-400 mt-1">{{ task.brief.description }}</p>
|
||||
<p class="text-xs text-gray-600 mt-1">Автопилот не смог выполнить это автоматически. Примите меры вручную и нажмите «Решить вручную».</p>
|
||||
</div>
|
||||
|
||||
<!-- Dangerous skip warning banner -->
|
||||
<div v-if="task.dangerously_skipped" class="mb-3 px-3 py-2 border border-red-700 bg-red-950/40 rounded flex items-start gap-2">
|
||||
<span class="text-red-400 text-base shrink-0">⚠</span>
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-red-400">--dangerously-skip-permissions использовался в этой задаче</span>
|
||||
<p class="text-xs text-red-300/70 mt-0.5">Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="task.brief && !isManualEscalation" class="text-xs text-gray-500 mb-1">
|
||||
Brief: {{ JSON.stringify(task.brief) }}
|
||||
</div>
|
||||
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
|
||||
|
|
@ -361,6 +448,11 @@ async function changeStatus(newStatus: string) {
|
|||
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
|
||||
{{ autoMode ? '🔓 Auto' : '🔒 Review' }}
|
||||
</button>
|
||||
<button v-if="task.status === 'pending'"
|
||||
@click="openEdit"
|
||||
class="px-3 py-2 text-sm bg-gray-800/50 text-gray-400 border border-gray-700 rounded hover:bg-gray-800">
|
||||
✎ Edit
|
||||
</button>
|
||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||
@click="runPipeline"
|
||||
:disabled="polling"
|
||||
|
|
@ -368,6 +460,13 @@ async function changeStatus(newStatus: string) {
|
|||
<span v-if="polling" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ polling ? 'Pipeline running...' : '▶ Run Pipeline' }}
|
||||
</button>
|
||||
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
|
||||
@click="resolveManually"
|
||||
:disabled="resolvingManually"
|
||||
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900 disabled:opacity-50">
|
||||
<span v-if="resolvingManually" class="inline-block w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||
{{ resolvingManually ? 'Сохраняем...' : '✓ Решить вручную' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Approve Modal -->
|
||||
|
|
@ -438,5 +537,31 @@ async function changeStatus(newStatus: string) {
|
|||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Modal (pending tasks only) -->
|
||||
<Modal v-if="showEdit" title="Edit Task" @close="showEdit = false">
|
||||
<form @submit.prevent="saveEdit" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Title</label>
|
||||
<input v-model="editForm.title" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Brief</label>
|
||||
<textarea v-model="editForm.briefText" rows="4" placeholder="Task description..."
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Priority (1–10)</label>
|
||||
<input v-model.number="editForm.priority" type="number" min="1" max="10" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
|
||||
</div>
|
||||
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
|
||||
<button type="submit" :disabled="editLoading"
|
||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">
|
||||
{{ editLoading ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue