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 [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 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>
|
||||
)}
|
||||
</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'}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user