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:
saravanakumardb1 2026-06-01 02:19:38 -07:00
parent 5262583e8b
commit 928edad0af
4 changed files with 184 additions and 35 deletions

View File

@ -45,6 +45,7 @@ export default function FleetJobDetailPage() {
const [shipping, setShipping] = useState(false);
const [acting, setActing] = useState<OperatorAction | null>(null);
const [reviewing, setReviewing] = useState(false);
const [eventFilter, setEventFilter] = useState<string>('');
const [streamMode, setStreamMode] = useState<'connecting' | 'live' | 'polling'>('connecting');
const refresh = useCallback(async () => {
@ -263,34 +264,52 @@ export default function FleetJobDetailPage() {
{/* Pull request (surfaced from whichever run opened it) */}
{(() => {
const prRun = runs.find(r => r.prUrl);
if (!prRun?.prUrl) return null;
return (
<section
className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/20 p-3"
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"
if (prRun?.prUrl) {
return (
<section
className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/20 p-3"
aria-label="Pull request"
>
{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'
}`}
<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.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 didnt open a PR it ran without a <code>verify</code> step, or the
factory wasnt in PR mode. Set a verify command and requeue to produce one, or
<strong> Ship</strong> to promote as-is.
</span>
)}
</section>
);
</section>
);
}
return null;
})()}
{/* Review gate (multi-reviewer human gate) */}
@ -337,9 +356,41 @@ export default function FleetJobDetailPage() {
Polling
</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>
{events.length === 0 ? (
<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">
{groupTimelineEvents(events).map(g =>
@ -432,7 +483,31 @@ export default function FleetJobDetailPage() {
return (
<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 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">{r.result ?? 'running'}</td>
<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 [editing, setEditing] = useState(false);
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 [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 () => {
if (!draftBody.trim()) {
setErr('Prompt cannot be empty.');
@ -574,7 +663,18 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
setBusy('save');
setErr(null);
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);
await onChanged();
} catch (e) {
@ -632,14 +732,7 @@ function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void |
</span>
<div className="ml-auto flex items-center gap-2">
{editable && !editing && (
<Button
variant="secondary"
onClick={() => {
setDraftBody(job.bodyMd ?? '');
setEditing(true);
}}
aria-label="Edit prompt"
>
<Button variant="secondary" onClick={beginEdit} aria-label="Edit prompt and config">
Edit
</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"
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">
<Button onClick={save} disabled={busy !== null} aria-label="Save prompt">
{busy === 'save' ? 'Saving…' : 'Save'}

View File

@ -49,6 +49,12 @@ export interface FleetFactory {
/** Per-run cost / token / effort metrics reported by a factory. */
export interface FleetRunInsights {
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;
tokensOut?: number;
tokensCached?: number;

View File

@ -1037,6 +1037,9 @@ export async function releaseLease(
const runId = `${jobId}:run:${job.attempts}`;
await repo.updateRun(runId, jobId, {
...(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.prUrl ? { prUrl: report.prUrl } : {}),
...(report.branch ? { branch: report.branch } : {}),

View File

@ -117,6 +117,13 @@ export const ManifestSnapshotSchema = z.object({
export const InsightsSchema = z.object({
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(),
tokensOut: z.number().optional(),
tokensCached: z.number().optional(),