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:
saravanakumardb1 2026-06-01 02:38:35 -07:00
parent 6c31577cf2
commit d318e0fa2a
3 changed files with 61 additions and 3 deletions

View File

@ -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">

View File

@ -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 ─────────────────────────────────────────────────────────────────
/**

View File

@ -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);