feat: optional worker selection when creating workflow (API + frontend dropdown)

This commit is contained in:
Fam Zheng 2026-04-07 09:59:49 +01:00
parent 2cb9d9321e
commit 49a13d8f50
9 changed files with 63 additions and 20 deletions

View File

@ -20,7 +20,7 @@ pub struct ServiceInfo {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
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 },
}
@ -113,7 +113,7 @@ impl AgentManager {
/// Dispatch an event to a worker.
pub async fn send_event(self: &Arc<Self>, project_id: &str, event: AgentEvent) {
match event {
AgentEvent::NewRequirement { workflow_id, requirement, template_id } => {
AgentEvent::NewRequirement { workflow_id, requirement, template_id, worker } => {
// Generate title (heuristic)
let title = generate_title_heuristic(&requirement);
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)
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 {
if dispatch_result.is_ok() { break; }
tracing::warn!("Dispatch attempt {} failed, retrying in 5s...", attempt);
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 {
@ -247,7 +247,7 @@ impl AgentManager {
.clone()
};
match self.worker_mgr.assign_workflow(assign).await {
match self.worker_mgr.assign_workflow(assign, None).await {
Ok(name) => {
let _ = sqlx::query("UPDATE workflows SET status = 'executing', status_reason = '' WHERE id = ?")
.bind(&workflow_id).execute(&self.pool).await;

View File

@ -6,6 +6,7 @@ use crate::worker::WorkerInfo;
async fn list_workers(State(state): State<Arc<AppState>>) -> Json<Vec<WorkerInfo>> {
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();
Json(entries)
}

View File

@ -25,6 +25,8 @@ pub struct CreateWorkflow {
pub requirement: String,
#[serde(default)]
pub template_id: Option<String>,
#[serde(default)]
pub worker: Option<String>,
}
#[derive(Deserialize)]
@ -78,6 +80,7 @@ async fn create_workflow(
workflow_id: workflow.id.clone(),
requirement: workflow.requirement.clone(),
template_id: input.template_id,
worker: input.worker,
}).await;
Ok(Json(workflow))

View File

@ -211,6 +211,7 @@ async fn resume_workflows(pool: SqlitePool, agent_mgr: Arc<agent::AgentManager>)
workflow_id,
requirement,
template_id: None,
worker: None,
}).await;
}
}

View File

@ -69,6 +69,7 @@ async fn check_timers(pool: &SqlitePool, agent_mgr: &Arc<AgentManager>) -> anyho
workflow_id,
requirement: timer.requirement.clone(),
template_id: None,
worker: None,
}).await;
}

View File

@ -118,17 +118,22 @@ impl WorkerManager {
.collect()
}
/// Assign a workflow to the first available worker. Returns worker name.
pub async fn assign_workflow(&self, assign: ServerToWorker) -> Result<String, String> {
/// Assign a workflow to a worker. If `preferred` is specified, use that worker;
/// otherwise pick the first available.
pub async fn assign_workflow(&self, assign: ServerToWorker, preferred: Option<&str>) -> Result<String, String> {
let workflow_id = match &assign {
ServerToWorker::WorkflowAssign { workflow_id, .. } => workflow_id.clone(),
_ => return Err("Not a workflow assignment".into()),
};
let workers = self.workers.read().await;
// Pick first worker (simple strategy for now)
let (name, worker) = workers.iter().next()
.ok_or_else(|| "No workers available".to_string())?;
let (name, worker) = if let Some(pref) = preferred {
workers.get_key_value(pref)
.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(|_| {
format!("Worker '{}' disconnected", name)

View File

@ -45,10 +45,10 @@ export const api = {
listWorkflows: (projectId: string) =>
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`, {
method: 'POST',
body: JSON.stringify({ requirement, template_id: templateId || undefined }),
body: JSON.stringify({ requirement, template_id: templateId || undefined, worker: worker || undefined }),
}),
listTemplates: () =>
@ -93,6 +93,9 @@ export const api = {
deleteTimer: (timerId: string) =>
request<void>(`/timers/${timerId}`, { method: 'DELETE' }),
listWorkers: () =>
request<{ name: string; cpu: string; memory: string; gpu: string }[]>('/workers'),
getKb: () => request<{ content: string }>('/kb'),
listArticles: () => request<KbArticleSummary[]>('/kb/articles'),

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { api } from '../api'
const props = defineProps<{
requirement: string
@ -7,13 +8,20 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
submit: [text: string]
submit: [text: string, worker?: string]
}>()
const input = ref('')
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) => {
if (val && editing.value && !input.value.trim()) {
editing.value = false
@ -23,7 +31,7 @@ watch(() => props.requirement, (val) => {
function submit() {
const text = input.value.trim()
if (!text) return
emit('submit', text)
emit('submit', text, selectedWorker.value || undefined)
editing.value = false
}
</script>
@ -47,7 +55,13 @@ function submit() {
rows="8"
@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>
</template>
@ -148,8 +162,23 @@ function submit() {
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 {
align-self: flex-end;
background: var(--accent);
color: var(--bg-primary);
font-weight: 600;

View File

@ -147,9 +147,9 @@ watch(() => props.projectId, () => {
setupWs()
})
async function onSubmitRequirement(text: string) {
async function onSubmitRequirement(text: string, worker?: string) {
try {
const wf = await api.createWorkflow(props.projectId, text)
const wf = await api.createWorkflow(props.projectId, text, undefined, worker)
workflow.value = wf
logEntries.value = []
planSteps.value = []