Refactor agent runtime: state machine, feedback processing, execution log

- Add state.rs with AgentState/Step/StepStatus/AgentPhase as single source of truth
- Extract prompts to markdown files loaded via include_str!
- Replace plan_steps table with execution_log + agent_state_snapshots
- Implement user feedback processing with docker-build-cache plan diff:
  load snapshot → LLM revise_plan → diff (title, description) → invalidate from first mismatch → resume
- run_agent_loop accepts optional initial_state for mid-execution resume
- Broadcast plan step status (done/running/pending) to frontend on step transitions
- Rewrite frontend types/components to match new API (ExecutionLogEntry, PlanStepInfo with status)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 08:54:43 +00:00
parent 7f6dafeab6
commit 46424cfbc4
16 changed files with 910 additions and 992 deletions

View File

@@ -1,4 +1,4 @@
import type { Project, Workflow, PlanStep, Comment, Timer, KbArticle, KbArticleSummary } from './types'
import type { Project, Workflow, ExecutionLogEntry, Comment, Timer, KbArticle, KbArticleSummary } from './types'
const BASE = '/api'
@@ -44,7 +44,7 @@ export const api = {
}),
listSteps: (workflowId: string) =>
request<PlanStep[]>(`/workflows/${workflowId}/steps`),
request<ExecutionLogEntry[]>(`/workflows/${workflowId}/steps`),
listComments: (workflowId: string) =>
request<Comment[]>(`/workflows/${workflowId}/comments`),

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import type { PlanStep, Comment } from '../types'
import type { ExecutionLogEntry, Comment } from '../types'
const props = defineProps<{
steps: PlanStep[]
planSteps: PlanStep[]
entries: ExecutionLogEntry[]
comments: Comment[]
requirement: string
createdAt: string
@@ -12,15 +11,6 @@ const props = defineProps<{
workflowId: string
}>()
// Map plan step id -> step_order for showing badge
const planStepOrderMap = computed(() => {
const m: Record<string, number> = {}
for (const ps of props.planSteps) {
m[ps.id] = ps.step_order
}
return m
})
const scrollContainer = ref<HTMLElement | null>(null)
const userScrolledUp = ref(false)
@@ -30,13 +20,13 @@ function onScroll() {
userScrolledUp.value = el.scrollTop + el.clientHeight < el.scrollHeight - 80
}
const expandedSteps = ref<Set<string>>(new Set())
const expandedEntries = ref<Set<string>>(new Set())
function toggleStep(id: string) {
if (expandedSteps.value.has(id)) {
expandedSteps.value.delete(id)
function toggleEntry(id: string) {
if (expandedEntries.value.has(id)) {
expandedEntries.value.delete(id)
} else {
expandedSteps.value.add(id)
expandedEntries.value.add(id)
}
}
@@ -59,67 +49,71 @@ function statusLabel(status: string) {
}
}
interface LogEntry {
function toolLabel(name: string): string {
switch (name) {
case 'execute': return '$'
case 'read_file': return 'Read'
case 'write_file': return 'Write'
case 'list_files': return 'List'
case 'text_response': return 'AI'
default: return name
}
}
interface LogItem {
id: string
type: 'requirement' | 'step' | 'comment' | 'report'
type: 'requirement' | 'entry' | 'comment' | 'report'
time: string
step?: PlanStep
entry?: ExecutionLogEntry
text?: string
}
const logEntries = computed(() => {
const entries: LogEntry[] = []
const logItems = computed(() => {
const items: LogItem[] = []
// Requirement
if (props.requirement) {
entries.push({ id: 'req', type: 'requirement', text: props.requirement, time: props.createdAt || '0' })
items.push({ id: 'req', type: 'requirement', text: props.requirement, time: props.createdAt || '0' })
}
// Steps
for (const step of props.steps) {
entries.push({ id: step.id, type: 'step', step, time: step.created_at || '' })
for (const e of props.entries) {
items.push({ id: e.id, type: 'entry', entry: e, time: e.created_at || '' })
}
// Comments
for (const c of props.comments) {
entries.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
items.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
}
// Sort by time, preserving order for entries without timestamps
entries.sort((a, b) => {
items.sort((a, b) => {
if (!a.time && !b.time) return 0
if (!a.time) return -1
if (!b.time) return 1
return a.time.localeCompare(b.time)
})
// Insert report links: after each contiguous block of steps that ends before a comment/requirement
if (props.workflowId && (props.workflowStatus === 'done' || props.workflowStatus === 'failed')) {
const result: LogEntry[] = []
let lastWasStep = false
for (const entry of entries) {
if (entry.type === 'step') {
lastWasStep = true
} else if (lastWasStep && (entry.type === 'comment' || entry.type === 'requirement')) {
// Insert report link before this comment/requirement
const result: LogItem[] = []
let lastWasEntry = false
for (const item of items) {
if (item.type === 'entry') {
lastWasEntry = true
} else if (lastWasEntry && (item.type === 'comment' || item.type === 'requirement')) {
result.push({ id: `report-${result.length}`, type: 'report', time: '' })
lastWasStep = false
lastWasEntry = false
} else {
lastWasStep = false
lastWasEntry = false
}
result.push(entry)
result.push(item)
}
// Final report link at the end if last entry was a step
if (lastWasStep) {
if (lastWasEntry) {
result.push({ id: 'report-final', type: 'report', time: '' })
}
return result
}
return entries
return items
})
watch(logEntries, () => {
watch(logItems, () => {
if (userScrolledUp.value) return
nextTick(() => {
const el = scrollContainer.value
@@ -134,37 +128,38 @@ watch(logEntries, () => {
<h2>日志</h2>
</div>
<div class="exec-list">
<template v-for="entry in logEntries" :key="entry.id">
<!-- User message (requirement or comment) -->
<div v-if="entry.type === 'requirement' || entry.type === 'comment'" class="log-user">
<span class="log-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
<span class="log-tag">{{ entry.type === 'requirement' ? '需求' : '反馈' }}</span>
<span class="log-text">{{ entry.text }}</span>
<template v-for="item in logItems" :key="item.id">
<!-- User message -->
<div v-if="item.type === 'requirement' || item.type === 'comment'" class="log-user">
<span class="log-time" v-if="item.time">{{ formatTime(item.time) }}</span>
<span class="log-tag">{{ item.type === 'requirement' ? '需求' : '反馈' }}</span>
<span class="log-text">{{ item.text }}</span>
</div>
<!-- Report link -->
<div v-else-if="entry.type === 'report'" class="report-link-bar">
<div v-else-if="item.type === 'report'" class="report-link-bar">
<a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 </a>
</div>
<!-- Step -->
<div v-else-if="entry.step" class="exec-item" :class="entry.step.status">
<div class="exec-header" @click="toggleStep(entry.step!.id)">
<span class="exec-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
<span v-if="entry.step.plan_step_id && planStepOrderMap[entry.step.plan_step_id]" class="step-badge">{{ planStepOrderMap[entry.step.plan_step_id] }}</span>
<span class="exec-toggle">{{ expandedSteps.has(entry.step!.id) ? '▾' : '▸' }}</span>
<span class="exec-desc">{{ entry.step.description }}</span>
<span class="exec-status" :class="entry.step.status">{{ statusLabel(entry.step.status) }}</span>
<!-- Execution log entry -->
<div v-else-if="item.entry" class="exec-item" :class="item.entry.status">
<div class="exec-header" @click="toggleEntry(item.entry!.id)">
<span class="exec-time" v-if="item.time">{{ formatTime(item.time) }}</span>
<span v-if="item.entry.step_order > 0" class="step-badge">{{ item.entry.step_order }}</span>
<span class="exec-toggle">{{ expandedEntries.has(item.entry!.id) ? '▾' : '▸' }}</span>
<span class="exec-tool">{{ toolLabel(item.entry.tool_name) }}</span>
<span class="exec-desc">{{ item.entry.tool_name === 'text_response' ? item.entry.output.slice(0, 80) : item.entry.tool_input.slice(0, 80) }}</span>
<span class="exec-status" :class="item.entry.status">{{ statusLabel(item.entry.status) }}</span>
</div>
<div v-if="expandedSteps.has(entry.step!.id)" class="exec-detail">
<div v-if="entry.step.command" class="exec-command">
<code>$ {{ entry.step.command }}</code>
<div v-if="expandedEntries.has(item.entry!.id)" class="exec-detail">
<div v-if="item.entry.tool_input && item.entry.tool_name !== 'text_response'" class="exec-command">
<code>{{ item.entry.tool_input }}</code>
</div>
<pre v-if="entry.step.output">{{ entry.step.output }}</pre>
<pre v-if="item.entry.output">{{ item.entry.output }}</pre>
</div>
</div>
</template>
<div v-if="!steps.length && !requirement" class="empty-state">
<div v-if="!entries.length && !requirement" class="empty-state">
提交需求后日志将显示在这里
</div>
</div>
@@ -269,10 +264,20 @@ watch(logEntries, () => {
flex-shrink: 0;
}
.exec-tool {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
.exec-desc {
color: var(--text-primary);
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exec-status {
@@ -285,7 +290,6 @@ watch(logEntries, () => {
.exec-status.done { background: var(--success); color: #fff; }
.exec-status.running { background: var(--accent); color: #fff; }
.exec-status.failed { background: var(--error); color: #fff; }
.exec-status.pending { background: var(--pending); color: #fff; }
.exec-detail {
border-top: 1px solid var(--border);
@@ -301,6 +305,8 @@ watch(logEntries, () => {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--accent);
white-space: pre-wrap;
word-break: break-all;
}
.exec-detail pre {

View File

@@ -1,22 +1,22 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { PlanStep } from '../types'
import type { PlanStepInfo } from '../types'
defineProps<{
steps: PlanStep[]
steps: PlanStepInfo[]
}>()
const expandedSteps = ref<Set<string>>(new Set())
const expandedSteps = ref<Set<number>>(new Set())
function toggleStep(id: string) {
if (expandedSteps.value.has(id)) {
expandedSteps.value.delete(id)
function toggleStep(order: number) {
if (expandedSteps.value.has(order)) {
expandedSteps.value.delete(order)
} else {
expandedSteps.value.add(id)
expandedSteps.value.add(order)
}
}
function statusIcon(status: string) {
function statusIcon(status?: string) {
switch (status) {
case 'done': return '✓'
case 'running': return '⟳'
@@ -34,17 +34,17 @@ function statusIcon(status: string) {
<div class="steps-list">
<div
v-for="step in steps"
:key="step.id"
:key="step.order"
class="step-item"
:class="step.status"
:class="step.status || 'pending'"
>
<div class="step-header" @click="step.command ? toggleStep(step.id) : undefined">
<div class="step-header" @click="step.command ? toggleStep(step.order) : undefined">
<span class="step-icon">{{ statusIcon(step.status) }}</span>
<span class="step-order">{{ step.step_order }}.</span>
<span class="step-order">{{ step.order }}.</span>
<span class="step-title">{{ step.description }}</span>
<span v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.id) ? '' : '' }}</span>
<span v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.order) ? '' : '' }}</span>
</div>
<div v-if="step.command && expandedSteps.has(step.id)" class="step-detail">
<div v-if="step.command && expandedSteps.has(step.order)" class="step-detail">
{{ step.command }}
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import RequirementSection from './RequirementSection.vue'
import PlanSection from './PlanSection.vue'
import ExecutionSection from './ExecutionSection.vue'
@@ -7,7 +7,7 @@ import CommentSection from './CommentSection.vue'
import TimerSection from './TimerSection.vue'
import { api } from '../api'
import { connectWs } from '../ws'
import type { Workflow, PlanStep, Comment } from '../types'
import type { Workflow, ExecutionLogEntry, PlanStepInfo, Comment } from '../types'
import type { WsMessage } from '../ws'
const props = defineProps<{
@@ -19,14 +19,12 @@ const emit = defineEmits<{
}>()
const workflow = ref<Workflow | null>(null)
const steps = ref<PlanStep[]>([])
const logEntries = ref<ExecutionLogEntry[]>([])
const planSteps = ref<PlanStepInfo[]>([])
const comments = ref<Comment[]>([])
const error = ref('')
const rightTab = ref<'log' | 'timers'>('log')
const planSteps = computed(() => steps.value.filter(s => s.kind === 'plan'))
const logSteps = computed(() => steps.value.filter(s => s.kind === 'log'))
let wsConn: { close: () => void } | null = null
async function loadData() {
@@ -35,15 +33,16 @@ async function loadData() {
const latest = workflows[0]
if (latest) {
workflow.value = latest
const [s, c] = await Promise.all([
const [entries, c] = await Promise.all([
api.listSteps(latest.id),
api.listComments(latest.id),
])
steps.value = s
logEntries.value = entries
comments.value = c
} else {
workflow.value = null
steps.value = []
logEntries.value = []
planSteps.value = []
comments.value = []
}
} catch (e: any) {
@@ -55,18 +54,18 @@ function handleWsMessage(msg: WsMessage) {
switch (msg.type) {
case 'PlanUpdate':
if (workflow.value && msg.workflow_id === workflow.value.id) {
api.listSteps(workflow.value.id).then(s => { steps.value = s })
planSteps.value = msg.steps.map(s => ({
order: s.order,
description: s.description,
command: s.command,
status: s.status as PlanStepInfo['status'],
}))
}
break
case 'StepStatusUpdate': {
const idx = steps.value.findIndex(s => s.id === msg.step_id)
const existing = steps.value[idx]
if (existing) {
steps.value[idx] = { ...existing, status: msg.status as PlanStep['status'], output: msg.output }
} else {
if (workflow.value) {
api.listSteps(workflow.value.id).then(s => { steps.value = s })
}
// New execution log entry — just refetch the list
if (workflow.value) {
api.listSteps(workflow.value.id).then(entries => { logEntries.value = entries })
}
break
}
@@ -117,7 +116,8 @@ async function onSubmitRequirement(text: string) {
try {
const wf = await api.createWorkflow(props.projectId, text)
workflow.value = wf
steps.value = []
logEntries.value = []
planSteps.value = []
comments.value = []
} catch (e: any) {
error.value = e.message
@@ -152,8 +152,7 @@ async function onSubmitComment(text: string) {
</div>
<ExecutionSection
v-show="rightTab === 'log'"
:steps="logSteps"
:planSteps="planSteps"
:entries="logEntries"
:comments="comments"
:requirement="workflow?.requirement || ''"
:createdAt="workflow?.created_at || ''"

View File

@@ -15,17 +15,22 @@ export interface Workflow {
report: string
}
export interface PlanStep {
export interface ExecutionLogEntry {
id: string
workflow_id: string
step_order: number
tool_name: string
tool_input: string
output: string
status: 'running' | 'done' | 'failed'
created_at: string
}
export interface PlanStepInfo {
order: number
description: string
command: string
status: 'pending' | 'running' | 'done' | 'failed'
output: string
created_at: string
kind: 'plan' | 'log'
plan_step_id: string
status?: 'pending' | 'running' | 'done' | 'failed'
}
export interface Comment {

View File

@@ -1,7 +1,7 @@
export interface WsPlanUpdate {
type: 'PlanUpdate'
workflow_id: string
steps: { order: number; description: string; command: string }[]
steps: { order: number; description: string; command: string; status?: string }[]
}
export interface WsStepStatusUpdate {