feat: optional worker selection when creating workflow (API + frontend dropdown)
This commit is contained in:
parent
2cb9d9321e
commit
49a13d8f50
10
src/agent.rs
10
src/agent.rs
@ -20,7 +20,7 @@ pub struct ServiceInfo {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum AgentEvent {
|
pub enum AgentEvent {
|
||||||
NewRequirement { workflow_id: String, requirement: String, template_id: Option<String> },
|
NewRequirement { workflow_id: String, requirement: String, template_id: Option<String>, #[serde(default)] worker: Option<String> },
|
||||||
Comment { workflow_id: String, content: String },
|
Comment { workflow_id: String, content: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,7 +113,7 @@ impl AgentManager {
|
|||||||
/// Dispatch an event to a worker.
|
/// Dispatch an event to a worker.
|
||||||
pub async fn send_event(self: &Arc<Self>, project_id: &str, event: AgentEvent) {
|
pub async fn send_event(self: &Arc<Self>, project_id: &str, event: AgentEvent) {
|
||||||
match event {
|
match event {
|
||||||
AgentEvent::NewRequirement { workflow_id, requirement, template_id } => {
|
AgentEvent::NewRequirement { workflow_id, requirement, template_id, worker } => {
|
||||||
// Generate title (heuristic)
|
// Generate title (heuristic)
|
||||||
let title = generate_title_heuristic(&requirement);
|
let title = generate_title_heuristic(&requirement);
|
||||||
let _ = sqlx::query("UPDATE projects SET name = ? WHERE id = ?")
|
let _ = sqlx::query("UPDATE projects SET name = ? WHERE id = ?")
|
||||||
@ -154,12 +154,12 @@ impl AgentManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Retry dispatch up to 3 times (worker might be reconnecting)
|
// Retry dispatch up to 3 times (worker might be reconnecting)
|
||||||
let mut dispatch_result = self.worker_mgr.assign_workflow(assign.clone()).await;
|
let mut dispatch_result = self.worker_mgr.assign_workflow(assign.clone(), worker.as_deref()).await;
|
||||||
for attempt in 1..3 {
|
for attempt in 1..3 {
|
||||||
if dispatch_result.is_ok() { break; }
|
if dispatch_result.is_ok() { break; }
|
||||||
tracing::warn!("Dispatch attempt {} failed, retrying in 5s...", attempt);
|
tracing::warn!("Dispatch attempt {} failed, retrying in 5s...", attempt);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
dispatch_result = self.worker_mgr.assign_workflow(assign.clone()).await;
|
dispatch_result = self.worker_mgr.assign_workflow(assign.clone(), worker.as_deref()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
match dispatch_result {
|
match dispatch_result {
|
||||||
@ -247,7 +247,7 @@ impl AgentManager {
|
|||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.worker_mgr.assign_workflow(assign).await {
|
match self.worker_mgr.assign_workflow(assign, None).await {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
let _ = sqlx::query("UPDATE workflows SET status = 'executing', status_reason = '' WHERE id = ?")
|
let _ = sqlx::query("UPDATE workflows SET status = 'executing', status_reason = '' WHERE id = ?")
|
||||||
.bind(&workflow_id).execute(&self.pool).await;
|
.bind(&workflow_id).execute(&self.pool).await;
|
||||||
|
|||||||
@ -6,6 +6,7 @@ use crate::worker::WorkerInfo;
|
|||||||
|
|
||||||
async fn list_workers(State(state): State<Arc<AppState>>) -> Json<Vec<WorkerInfo>> {
|
async fn list_workers(State(state): State<Arc<AppState>>) -> Json<Vec<WorkerInfo>> {
|
||||||
let workers = state.agent_mgr.worker_mgr.list().await;
|
let workers = state.agent_mgr.worker_mgr.list().await;
|
||||||
|
// WorkerInfo already contains `name` field from registration
|
||||||
let entries: Vec<WorkerInfo> = workers.into_iter().map(|(_, info)| info).collect();
|
let entries: Vec<WorkerInfo> = workers.into_iter().map(|(_, info)| info).collect();
|
||||||
Json(entries)
|
Json(entries)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,8 @@ pub struct CreateWorkflow {
|
|||||||
pub requirement: String,
|
pub requirement: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub template_id: Option<String>,
|
pub template_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub worker: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -78,6 +80,7 @@ async fn create_workflow(
|
|||||||
workflow_id: workflow.id.clone(),
|
workflow_id: workflow.id.clone(),
|
||||||
requirement: workflow.requirement.clone(),
|
requirement: workflow.requirement.clone(),
|
||||||
template_id: input.template_id,
|
template_id: input.template_id,
|
||||||
|
worker: input.worker,
|
||||||
}).await;
|
}).await;
|
||||||
|
|
||||||
Ok(Json(workflow))
|
Ok(Json(workflow))
|
||||||
|
|||||||
@ -211,6 +211,7 @@ async fn resume_workflows(pool: SqlitePool, agent_mgr: Arc<agent::AgentManager>)
|
|||||||
workflow_id,
|
workflow_id,
|
||||||
requirement,
|
requirement,
|
||||||
template_id: None,
|
template_id: None,
|
||||||
|
worker: None,
|
||||||
}).await;
|
}).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,7 @@ async fn check_timers(pool: &SqlitePool, agent_mgr: &Arc<AgentManager>) -> anyho
|
|||||||
workflow_id,
|
workflow_id,
|
||||||
requirement: timer.requirement.clone(),
|
requirement: timer.requirement.clone(),
|
||||||
template_id: None,
|
template_id: None,
|
||||||
|
worker: None,
|
||||||
}).await;
|
}).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -118,17 +118,22 @@ impl WorkerManager {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assign a workflow to the first available worker. Returns worker name.
|
/// Assign a workflow to a worker. If `preferred` is specified, use that worker;
|
||||||
pub async fn assign_workflow(&self, assign: ServerToWorker) -> Result<String, String> {
|
/// otherwise pick the first available.
|
||||||
|
pub async fn assign_workflow(&self, assign: ServerToWorker, preferred: Option<&str>) -> Result<String, String> {
|
||||||
let workflow_id = match &assign {
|
let workflow_id = match &assign {
|
||||||
ServerToWorker::WorkflowAssign { workflow_id, .. } => workflow_id.clone(),
|
ServerToWorker::WorkflowAssign { workflow_id, .. } => workflow_id.clone(),
|
||||||
_ => return Err("Not a workflow assignment".into()),
|
_ => return Err("Not a workflow assignment".into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let workers = self.workers.read().await;
|
let workers = self.workers.read().await;
|
||||||
// Pick first worker (simple strategy for now)
|
let (name, worker) = if let Some(pref) = preferred {
|
||||||
let (name, worker) = workers.iter().next()
|
workers.get_key_value(pref)
|
||||||
.ok_or_else(|| "No workers available".to_string())?;
|
.ok_or_else(|| format!("Worker '{}' not available", pref))?
|
||||||
|
} else {
|
||||||
|
workers.iter().next()
|
||||||
|
.ok_or_else(|| "No workers available".to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
worker.tx.send(assign).await.map_err(|_| {
|
worker.tx.send(assign).await.map_err(|_| {
|
||||||
format!("Worker '{}' disconnected", name)
|
format!("Worker '{}' disconnected", name)
|
||||||
|
|||||||
@ -45,10 +45,10 @@ export const api = {
|
|||||||
listWorkflows: (projectId: string) =>
|
listWorkflows: (projectId: string) =>
|
||||||
request<Workflow[]>(`/projects/${projectId}/workflows`),
|
request<Workflow[]>(`/projects/${projectId}/workflows`),
|
||||||
|
|
||||||
createWorkflow: (projectId: string, requirement: string, templateId?: string) =>
|
createWorkflow: (projectId: string, requirement: string, templateId?: string, worker?: string) =>
|
||||||
request<Workflow>(`/projects/${projectId}/workflows`, {
|
request<Workflow>(`/projects/${projectId}/workflows`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ requirement, template_id: templateId || undefined }),
|
body: JSON.stringify({ requirement, template_id: templateId || undefined, worker: worker || undefined }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listTemplates: () =>
|
listTemplates: () =>
|
||||||
@ -93,6 +93,9 @@ export const api = {
|
|||||||
deleteTimer: (timerId: string) =>
|
deleteTimer: (timerId: string) =>
|
||||||
request<void>(`/timers/${timerId}`, { method: 'DELETE' }),
|
request<void>(`/timers/${timerId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
listWorkers: () =>
|
||||||
|
request<{ name: string; cpu: string; memory: string; gpu: string }[]>('/workers'),
|
||||||
|
|
||||||
getKb: () => request<{ content: string }>('/kb'),
|
getKb: () => request<{ content: string }>('/kb'),
|
||||||
|
|
||||||
listArticles: () => request<KbArticleSummary[]>('/kb/articles'),
|
listArticles: () => request<KbArticleSummary[]>('/kb/articles'),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
requirement: string
|
requirement: string
|
||||||
@ -7,13 +8,20 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
submit: [text: string]
|
submit: [text: string, worker?: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
const editing = ref(!props.requirement)
|
const editing = ref(!props.requirement)
|
||||||
|
const workers = ref<{ name: string }[]>([])
|
||||||
|
const selectedWorker = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
workers.value = await api.listWorkers()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
|
||||||
// 当 requirement 从外部更新(如 loadData 完成),自动退出编辑模式
|
|
||||||
watch(() => props.requirement, (val) => {
|
watch(() => props.requirement, (val) => {
|
||||||
if (val && editing.value && !input.value.trim()) {
|
if (val && editing.value && !input.value.trim()) {
|
||||||
editing.value = false
|
editing.value = false
|
||||||
@ -23,7 +31,7 @@ watch(() => props.requirement, (val) => {
|
|||||||
function submit() {
|
function submit() {
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
emit('submit', text)
|
emit('submit', text, selectedWorker.value || undefined)
|
||||||
editing.value = false
|
editing.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -47,7 +55,13 @@ function submit() {
|
|||||||
rows="8"
|
rows="8"
|
||||||
@keydown.ctrl.enter="submit"
|
@keydown.ctrl.enter="submit"
|
||||||
/>
|
/>
|
||||||
<button class="btn-submit" @click="submit">提交需求</button>
|
<div class="submit-row">
|
||||||
|
<select v-if="workers.length" v-model="selectedWorker" class="worker-select">
|
||||||
|
<option value="">自动选择 Worker</option>
|
||||||
|
<option v-for="w in workers" :key="w.name" :value="w.name">{{ w.name }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-submit" @click="submit">提交需求</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -148,8 +162,23 @@ function submit() {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.submit-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-select {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-submit {
|
.btn-submit {
|
||||||
align-self: flex-end;
|
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@ -147,9 +147,9 @@ watch(() => props.projectId, () => {
|
|||||||
setupWs()
|
setupWs()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmitRequirement(text: string) {
|
async function onSubmitRequirement(text: string, worker?: string) {
|
||||||
try {
|
try {
|
||||||
const wf = await api.createWorkflow(props.projectId, text)
|
const wf = await api.createWorkflow(props.projectId, text, undefined, worker)
|
||||||
workflow.value = wf
|
workflow.value = wf
|
||||||
logEntries.value = []
|
logEntries.value = []
|
||||||
planSteps.value = []
|
planSteps.value = []
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user