feat(fleet): surface engine + agent session, editable config, timeline filter
Backend: insights now carry engine + sessionId/sessionUrl; releaseLease promotes the reported engine onto the run (was created with the abstract engineClass, usually 'unknown'). tracker-web job detail: - Runs: show the concrete engine (insights.engine, falls back off 'unknown') and the agent session (Devin session id with a `devin --resume <id>` hint, or a link when a sessionUrl is present). - PromptCard: edit repo/baseBranch/verify/autoMerge (not just the prompt) while draft/queued/blocked. - Timeline: filter by event type (default collapses heartbeat runs). - Show a "no PR — needs verify / not PR mode" hint when parked in review. 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
5262583e8b
commit
928edad0af
@ -45,6 +45,7 @@ export default function FleetJobDetailPage() {
|
|||||||
const [shipping, setShipping] = useState(false);
|
const [shipping, setShipping] = useState(false);
|
||||||
const [acting, setActing] = useState<OperatorAction | null>(null);
|
const [acting, setActing] = useState<OperatorAction | null>(null);
|
||||||
const [reviewing, setReviewing] = useState(false);
|
const [reviewing, setReviewing] = useState(false);
|
||||||
|
const [eventFilter, setEventFilter] = useState<string>('');
|
||||||
const [streamMode, setStreamMode] = useState<'connecting' | 'live' | 'polling'>('connecting');
|
const [streamMode, setStreamMode] = useState<'connecting' | 'live' | 'polling'>('connecting');
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
@ -263,34 +264,52 @@ export default function FleetJobDetailPage() {
|
|||||||
{/* Pull request (surfaced from whichever run opened it) */}
|
{/* Pull request (surfaced from whichever run opened it) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const prRun = runs.find(r => r.prUrl);
|
const prRun = runs.find(r => r.prUrl);
|
||||||
if (!prRun?.prUrl) return null;
|
if (prRun?.prUrl) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/20 p-3"
|
className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/20 p-3"
|
||||||
aria-label="Pull request"
|
aria-label="Pull request"
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold">Pull Request</span>
|
|
||||||
<a
|
|
||||||
href={prRun.prUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-primary hover:underline break-all"
|
|
||||||
>
|
>
|
||||||
{prRun.prUrl} ↗
|
<span className="text-sm font-semibold">Pull Request</span>
|
||||||
</a>
|
<a
|
||||||
{prRun.prState && (
|
href={prRun.prUrl}
|
||||||
<span
|
target="_blank"
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
rel="noopener noreferrer"
|
||||||
prRun.prState === 'merged'
|
className="text-sm text-primary hover:underline break-all"
|
||||||
? 'bg-green-600/15 text-green-700 dark:text-green-400'
|
|
||||||
: 'bg-muted text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{prRun.prState}
|
{prRun.prUrl} ↗
|
||||||
|
</a>
|
||||||
|
{prRun.prState && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
prRun.prState === 'merged'
|
||||||
|
? 'bg-green-600/15 text-green-700 dark:text-green-400'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{prRun.prState}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Parked in review with no PR — explain why + what to do.
|
||||||
|
if (job.repo && (job.stage === 'review' || job.stage === 'testing')) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 text-sm"
|
||||||
|
aria-label="No pull request"
|
||||||
|
>
|
||||||
|
<span className="font-semibold">No pull request yet.</span>{' '}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
This run didn’t open a PR — it ran without a <code>verify</code> step, or the
|
||||||
|
factory wasn’t in PR mode. Set a verify command and requeue to produce one, or
|
||||||
|
<strong> Ship</strong> to promote as-is.
|
||||||
</span>
|
</span>
|
||||||
)}
|
</section>
|
||||||
</section>
|
);
|
||||||
);
|
}
|
||||||
|
return null;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Review gate (multi-reviewer human gate) */}
|
{/* Review gate (multi-reviewer human gate) */}
|
||||||
@ -337,9 +356,41 @@ export default function FleetJobDetailPage() {
|
|||||||
Polling
|
Polling
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{events.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={eventFilter}
|
||||||
|
onChange={e => setEventFilter(e.target.value)}
|
||||||
|
aria-label="Filter timeline by event type"
|
||||||
|
className="ml-auto rounded border bg-background px-2 py-1 text-xs font-normal"
|
||||||
|
>
|
||||||
|
<option value="">All events</option>
|
||||||
|
{[...new Set(events.map(e => e.type))].sort().map(t => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm">No events recorded.</p>
|
<p className="text-muted-foreground text-sm">No events recorded.</p>
|
||||||
|
) : eventFilter ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{events
|
||||||
|
.filter(e => e.type === eventFilter)
|
||||||
|
.map(e => (
|
||||||
|
<li
|
||||||
|
key={e.id}
|
||||||
|
className="flex items-start gap-3 text-sm border-l-2 border-muted pl-3 py-1"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||||
|
{new Date(e.at).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{e.type}</span>
|
||||||
|
{e.actor && <span className="text-muted-foreground">by {e.actor}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{groupTimelineEvents(events).map(g =>
|
{groupTimelineEvents(events).map(g =>
|
||||||
@ -432,7 +483,31 @@ export default function FleetJobDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<tr key={r.id} className="border-b last:border-0">
|
<tr key={r.id} className="border-b last:border-0">
|
||||||
<td className="py-2 pr-4">#{r.attempt}</td>
|
<td className="py-2 pr-4">#{r.attempt}</td>
|
||||||
<td className="py-2 pr-4 font-mono text-xs">{r.engine}</td>
|
<td className="py-2 pr-4 font-mono text-xs">
|
||||||
|
{ins.engine ?? (r.engine && r.engine !== 'unknown' ? r.engine : '—')}
|
||||||
|
{ins.sessionId && (
|
||||||
|
<div className="mt-0.5">
|
||||||
|
{ins.sessionUrl ? (
|
||||||
|
<a
|
||||||
|
href={ins.sessionUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
title={`Open agent session ${ins.sessionId}`}
|
||||||
|
>
|
||||||
|
open session ↗
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title={`Resume locally: devin --resume ${ins.sessionId}`}
|
||||||
|
>
|
||||||
|
session: {ins.sessionId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="py-2 pr-4 font-mono text-xs">{r.factoryId ?? '—'}</td>
|
<td className="py-2 pr-4 font-mono text-xs">{r.factoryId ?? '—'}</td>
|
||||||
<td className="py-2 pr-4">{r.result ?? 'running'}</td>
|
<td className="py-2 pr-4">{r.result ?? 'running'}</td>
|
||||||
<td className="py-2 pr-4 text-xs text-muted-foreground">
|
<td className="py-2 pr-4 text-xs text-muted-foreground">
|
||||||
@ -563,9 +638,23 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
|||||||
const editable = job.stage === 'draft' || job.stage === 'queued' || job.stage === 'blocked';
|
const editable = job.stage === 'draft' || job.stage === 'queued' || job.stage === 'blocked';
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draftBody, setDraftBody] = useState(job.bodyMd ?? '');
|
const [draftBody, setDraftBody] = useState(job.bodyMd ?? '');
|
||||||
|
const [editRepo, setEditRepo] = useState(job.repo ?? '');
|
||||||
|
const [editBranch, setEditBranch] = useState(job.baseBranch ?? '');
|
||||||
|
const [editVerify, setEditVerify] = useState(job.verify ?? '');
|
||||||
|
const [editAutoMerge, setEditAutoMerge] = useState(!!job.autoMerge);
|
||||||
const [busy, setBusy] = useState<null | 'save' | 'submit'>(null);
|
const [busy, setBusy] = useState<null | 'save' | 'submit'>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const beginEdit = () => {
|
||||||
|
setDraftBody(job.bodyMd ?? '');
|
||||||
|
setEditRepo(job.repo ?? '');
|
||||||
|
setEditBranch(job.baseBranch ?? '');
|
||||||
|
setEditVerify(job.verify ?? '');
|
||||||
|
setEditAutoMerge(!!job.autoMerge);
|
||||||
|
setErr(null);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!draftBody.trim()) {
|
if (!draftBody.trim()) {
|
||||||
setErr('Prompt cannot be empty.');
|
setErr('Prompt cannot be empty.');
|
||||||
@ -574,7 +663,18 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
|||||||
setBusy('save');
|
setBusy('save');
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
await updateDraft(job.id, { bodyMd: draftBody.trim() });
|
const repo = editRepo.trim();
|
||||||
|
await updateDraft(job.id, {
|
||||||
|
bodyMd: draftBody.trim(),
|
||||||
|
...(repo
|
||||||
|
? {
|
||||||
|
repo,
|
||||||
|
baseBranch: editBranch.trim() || 'main',
|
||||||
|
autoMerge: editAutoMerge,
|
||||||
|
...(editVerify.trim() ? { verify: editVerify.trim() } : {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
await onChanged();
|
await onChanged();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -632,14 +732,7 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
|||||||
</span>
|
</span>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{editable && !editing && (
|
{editable && !editing && (
|
||||||
<Button
|
<Button variant="secondary" onClick={beginEdit} aria-label="Edit prompt and config">
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setDraftBody(job.bodyMd ?? '');
|
|
||||||
setEditing(true);
|
|
||||||
}}
|
|
||||||
aria-label="Edit prompt"
|
|
||||||
>
|
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -660,6 +753,46 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
|
|||||||
className="w-full rounded-lg border bg-background p-3 text-sm font-mono"
|
className="w-full rounded-lg border bg-background p-3 text-sm font-mono"
|
||||||
aria-label="Edit job prompt"
|
aria-label="Edit job prompt"
|
||||||
/>
|
/>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Repo (optional — opens a PR)
|
||||||
|
<input
|
||||||
|
value={editRepo}
|
||||||
|
onChange={e => setEditRepo(e.target.value)}
|
||||||
|
placeholder="e.g. learning_ai_notes"
|
||||||
|
className="mt-1 w-full rounded border bg-background px-2 py-1 text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Base branch
|
||||||
|
<input
|
||||||
|
value={editBranch}
|
||||||
|
onChange={e => setEditBranch(e.target.value)}
|
||||||
|
placeholder="main"
|
||||||
|
disabled={!editRepo.trim()}
|
||||||
|
className="mt-1 w-full rounded border bg-background px-2 py-1 text-sm font-mono disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Verify command (needed to open a PR)
|
||||||
|
<input
|
||||||
|
value={editVerify}
|
||||||
|
onChange={e => setEditVerify(e.target.value)}
|
||||||
|
placeholder="e.g. pnpm run verify"
|
||||||
|
disabled={!editRepo.trim()}
|
||||||
|
className="mt-1 w-full rounded border bg-background px-2 py-1 text-sm font-mono disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 self-end text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editAutoMerge}
|
||||||
|
onChange={e => setEditAutoMerge(e.target.checked)}
|
||||||
|
disabled={!editRepo.trim()}
|
||||||
|
/>
|
||||||
|
Auto-merge PR
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button onClick={save} disabled={busy !== null} aria-label="Save prompt">
|
<Button onClick={save} disabled={busy !== null} aria-label="Save prompt">
|
||||||
{busy === 'save' ? 'Saving…' : 'Save'}
|
{busy === 'save' ? 'Saving…' : 'Save'}
|
||||||
|
|||||||
@ -49,6 +49,12 @@ export interface FleetFactory {
|
|||||||
/** Per-run cost / token / effort metrics reported by a factory. */
|
/** Per-run cost / token / effort metrics reported by a factory. */
|
||||||
export interface FleetRunInsights {
|
export interface FleetRunInsights {
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/** Concrete engine the factory ran (devin/claude/codex), reported at run time. */
|
||||||
|
engine?: string;
|
||||||
|
/** Agent session handle (e.g. a Devin session id) for traceability/recovery. */
|
||||||
|
sessionId?: string;
|
||||||
|
/** Web URL for the agent session, when the engine exposes one. */
|
||||||
|
sessionUrl?: string;
|
||||||
tokensIn?: number;
|
tokensIn?: number;
|
||||||
tokensOut?: number;
|
tokensOut?: number;
|
||||||
tokensCached?: number;
|
tokensCached?: number;
|
||||||
|
|||||||
@ -1037,6 +1037,9 @@ export async function releaseLease(
|
|||||||
const runId = `${jobId}:run:${job.attempts}`;
|
const runId = `${jobId}:run:${job.attempts}`;
|
||||||
await repo.updateRun(runId, jobId, {
|
await repo.updateRun(runId, jobId, {
|
||||||
...(report.insights ? { insights: report.insights } : {}),
|
...(report.insights ? { insights: report.insights } : {}),
|
||||||
|
// Promote the concrete engine the factory reported onto the run (the run was
|
||||||
|
// created with the job's abstract engineClass, often 'unknown').
|
||||||
|
...(report.insights?.engine ? { engine: report.insights.engine } : {}),
|
||||||
...(report.result ? { result: report.result } : {}),
|
...(report.result ? { result: report.result } : {}),
|
||||||
...(report.prUrl ? { prUrl: report.prUrl } : {}),
|
...(report.prUrl ? { prUrl: report.prUrl } : {}),
|
||||||
...(report.branch ? { branch: report.branch } : {}),
|
...(report.branch ? { branch: report.branch } : {}),
|
||||||
|
|||||||
@ -117,6 +117,13 @@ export const ManifestSnapshotSchema = z.object({
|
|||||||
|
|
||||||
export const InsightsSchema = z.object({
|
export const InsightsSchema = z.object({
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
|
/** Concrete engine the factory actually ran (e.g. `devin`/`claude`/`codex`) —
|
||||||
|
* resolved at run time, unlike the job's abstract `engineClass`. */
|
||||||
|
engine: z.string().optional(),
|
||||||
|
/** Agent session handle for traceability/recovery (e.g. a Devin session id). */
|
||||||
|
sessionId: z.string().optional(),
|
||||||
|
/** Web URL for the agent session, when the engine exposes one. */
|
||||||
|
sessionUrl: z.string().optional(),
|
||||||
tokensIn: z.number().optional(),
|
tokensIn: z.number().optional(),
|
||||||
tokensOut: z.number().optional(),
|
tokensOut: z.number().optional(),
|
||||||
tokensCached: z.number().optional(),
|
tokensCached: z.number().optional(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user