feat(fleet): engine picker offers only engines the factory advertises
Add GET /fleet/factories (lists a product's factory docs with capabilities) — also fixes the fleet map's empty factory cards (listFactories had no route and silently returned []). The New-Job form now loads the selected factory's engine:* capabilities and constrains the engine dropdown to those (e.g. hides codex when the host doesn't have it), keeping the current pick valid; falls back to all engines when capabilities are unknown. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
6c31577cf2
commit
d318e0fa2a
@ -8,6 +8,7 @@ import { useAuth } from '@/lib/auth-context';
|
||||
import {
|
||||
listJobs,
|
||||
submitJob,
|
||||
availableEnginesForProduct,
|
||||
FLEET_ENGINES,
|
||||
type FleetJob,
|
||||
type FleetEngine,
|
||||
@ -99,6 +100,9 @@ export default function FleetJobsPage() {
|
||||
const [body, setBody] = useState('');
|
||||
const [priority, setPriority] = useState<'critical' | 'high' | 'medium' | 'low'>('high');
|
||||
const [engine, setEngine] = useState<FleetEngine>('devin');
|
||||
// Engines the selected factory's product actually advertises ([] ⇒ unknown,
|
||||
// offer all). Keeps users from picking an engine the host can't run.
|
||||
const [engineOptions, setEngineOptions] = useState<FleetEngine[]>([]);
|
||||
// Empty by default: no agent-queue factory advertises a `build` capability
|
||||
// (caps are os:* / engine:* / node:* / has:*), so a non-empty default here makes
|
||||
// the job unroutable. Leave blank ⇒ any capable factory for the product claims it.
|
||||
@ -133,6 +137,31 @@ export default function FleetJobsPage() {
|
||||
useEffect(() => {
|
||||
setHideShipped(localStorage.getItem('fleet_hide_shipped') === '1');
|
||||
}, []);
|
||||
|
||||
// When the target factory changes, learn which engines its product advertises
|
||||
// and constrain the picker (so you can't pick an engine the host can't run).
|
||||
useEffect(() => {
|
||||
if (!token || !showForm) return;
|
||||
const factory = FLEET_FACTORIES.find(f => f.id === factoryId) ?? FLEET_FACTORIES[0];
|
||||
let cancelled = false;
|
||||
availableEnginesForProduct(factory.productId)
|
||||
.then(engines => {
|
||||
if (cancelled) return;
|
||||
setEngineOptions(engines);
|
||||
// Keep the selection valid: prefer the current pick, else devin, else first.
|
||||
if (engines.length > 0) {
|
||||
setEngine(prev =>
|
||||
engines.includes(prev) ? prev : engines.includes('devin') ? 'devin' : engines[0]!
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setEngineOptions([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, showForm, factoryId]);
|
||||
const toggleHideShipped = useCallback((next: boolean) => {
|
||||
setHideShipped(next);
|
||||
localStorage.setItem('fleet_hide_shipped', next ? '1' : '0');
|
||||
@ -290,12 +319,15 @@ export default function FleetJobsPage() {
|
||||
onChange={e => setEngine(e.target.value as FleetEngine)}
|
||||
className="rounded border bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
{FLEET_ENGINES.map(e => (
|
||||
{(engineOptions.length > 0 ? engineOptions : FLEET_ENGINES).map(e => (
|
||||
<option key={e} value={e}>
|
||||
{e}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{engineOptions.length > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">advertised by this factory</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="job-caps" className="mb-1 block text-sm font-medium">
|
||||
|
||||
@ -520,14 +520,32 @@ export async function getJobExplain(jobId: string): Promise<JobExplain | null> {
|
||||
|
||||
// ── Factories ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listFactories(): Promise<{ factories: FleetFactory[] }> {
|
||||
export async function listFactories(productId?: string): Promise<{ factories: FleetFactory[] }> {
|
||||
const headers = productId ? { 'x-product-id': productId } : undefined;
|
||||
try {
|
||||
return await apiFetch('/factories');
|
||||
return await apiFetch('/factories', headers ? { headers } : undefined);
|
||||
} catch {
|
||||
return { factories: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Concrete engines a product's live factories advertise (`engine:*` capabilities),
|
||||
* intersected with the known engine set. Empty ⇒ unknown (caller should not filter). */
|
||||
export async function availableEnginesForProduct(productId?: string): Promise<FleetEngine[]> {
|
||||
const { factories } = await listFactories(productId);
|
||||
const seen = new Set<FleetEngine>();
|
||||
for (const f of factories) {
|
||||
if (f.health === 'down') continue;
|
||||
for (const cap of f.capabilities ?? []) {
|
||||
if (cap.startsWith('engine:')) {
|
||||
const e = cap.slice('engine:'.length) as FleetEngine;
|
||||
if ((FLEET_ENGINES as readonly string[]).includes(e)) seen.add(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
// ── Budgets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@ -332,6 +332,14 @@ export async function fleetRoutes(app: FastifyInstance) {
|
||||
return { ok: true, factory };
|
||||
});
|
||||
|
||||
// ── List factories for the product (powers the fleet map + engine picker) ──
|
||||
app.get('/fleet/factories', async req => {
|
||||
await extractAuth(req);
|
||||
const pid = getRequestProductId(req);
|
||||
const factories = await repo.listFactories(pid);
|
||||
return { factories };
|
||||
});
|
||||
|
||||
// ── Runs ──
|
||||
app.get('/fleet/jobs/:id/runs', async req => {
|
||||
await extractAuth(req);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user