Merge feat/fleet-pr-deliverable: PR deliverables for fleet jobs
This commit is contained in:
commit
d350159025
@ -351,7 +351,8 @@ export default function FleetJobDetailPage() {
|
||||
<th className="pb-2 pr-4">Duration</th>
|
||||
<th className="pb-2 pr-4">Model</th>
|
||||
<th className="pb-2 pr-4 text-right">Tokens (in/out)</th>
|
||||
<th className="pb-2 text-right">Cost</th>
|
||||
<th className="pb-2 pr-4 text-right">Cost</th>
|
||||
<th className="pb-2">PR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -401,6 +402,21 @@ export default function FleetJobDetailPage() {
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{r.prUrl ? (
|
||||
<a
|
||||
href={r.prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
aria-label="Open pull request"
|
||||
>
|
||||
PR ↗
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -30,8 +30,10 @@ export default function FleetJobsPage() {
|
||||
// New-job form state
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [body, setBody] = useState('');
|
||||
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('high');
|
||||
const [priority, setPriority] = useState<'critical' | 'high' | 'medium' | 'low'>('high');
|
||||
const [caps, setCaps] = useState('build');
|
||||
const [repo, setRepo] = useState('');
|
||||
const [baseBranch, setBaseBranch] = useState('main');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
@ -72,6 +74,7 @@ export default function FleetJobsPage() {
|
||||
bodyMd: body.trim(),
|
||||
priority,
|
||||
capabilities,
|
||||
...(repo.trim() ? { repo: repo.trim(), baseBranch: baseBranch.trim() || 'main' } : {}),
|
||||
});
|
||||
setSubmitMsg({ ok: true, text: `Submitted ${job.id} (stage: ${job.stage}).` });
|
||||
setBody('');
|
||||
@ -82,7 +85,7 @@ export default function FleetJobsPage() {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [body, caps, priority, refresh]);
|
||||
}, [body, caps, priority, repo, baseBranch, refresh]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
@ -122,11 +125,14 @@ export default function FleetJobsPage() {
|
||||
<select
|
||||
id="job-priority"
|
||||
value={priority}
|
||||
onChange={e => setPriority(e.target.value as 'low' | 'normal' | 'high')}
|
||||
onChange={e =>
|
||||
setPriority(e.target.value as 'critical' | 'high' | 'medium' | 'low')
|
||||
}
|
||||
className="rounded border bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="critical">critical</option>
|
||||
<option value="high">high</option>
|
||||
<option value="normal">normal</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="low">low</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -141,6 +147,31 @@ export default function FleetJobsPage() {
|
||||
className="rounded border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="job-repo" className="mb-1 block text-sm font-medium">
|
||||
Repo (optional, PR mode)
|
||||
</label>
|
||||
<input
|
||||
id="job-repo"
|
||||
value={repo}
|
||||
onChange={e => setRepo(e.target.value)}
|
||||
placeholder="owner/name"
|
||||
className="rounded border bg-background px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
{repo.trim() && (
|
||||
<div>
|
||||
<label htmlFor="job-base" className="mb-1 block text-sm font-medium">
|
||||
Base branch
|
||||
</label>
|
||||
<input
|
||||
id="job-base"
|
||||
value={baseBranch}
|
||||
onChange={e => setBaseBranch(e.target.value)}
|
||||
className="rounded border bg-background px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleSubmit} disabled={submitting} aria-label="Submit new job">
|
||||
{submitting ? 'Submitting…' : 'Submit Job'}
|
||||
</Button>
|
||||
|
||||
@ -63,6 +63,8 @@ export interface FleetRun {
|
||||
endedAt?: string;
|
||||
result?: string;
|
||||
insights: FleetRunInsights;
|
||||
prUrl?: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface FleetEvent {
|
||||
@ -195,8 +197,11 @@ export async function getJob(id: string): Promise<FleetJob | null> {
|
||||
export interface SubmitJobBody {
|
||||
idempotencyKey: string;
|
||||
bodyMd: string;
|
||||
priority?: 'low' | 'normal' | 'high';
|
||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||
capabilities?: string[];
|
||||
/** PR mode: open a PR against this repo (`owner/name` or clone URL) + base branch. */
|
||||
repo?: string;
|
||||
baseBranch?: string;
|
||||
}
|
||||
|
||||
/** Submit a new fleet job. Returns the created job. */
|
||||
|
||||
@ -150,6 +150,18 @@ release), the coordinator mirrors the outcome onto the latest **run**
|
||||
run's `insights.costUsd`. So the dashboard's per-run result/cost/tokens stay
|
||||
consistent with the job stage.
|
||||
|
||||
### PR deliverable (PR mode)
|
||||
|
||||
A job may carry an optional **`repo`** (`owner/name` or a clone URL) + **`baseBranch`**.
|
||||
When the factory runs with `AQ_FLEET_PR=1`, it runs the agent in an isolated checkout
|
||||
on branch `aq/job/<jobId>`, then commits, pushes, and opens a PR via `gh`. The PR URL
|
||||
|
||||
- branch are reported on lease release and recorded on the **run** (`run.prUrl`,
|
||||
`run.branch`) — the dashboard shows a **PR ↗** link in the job's Runs table. Submit
|
||||
`repo`/`baseBranch` from the dashboard "New Job" form or the `POST /fleet/jobs` body.
|
||||
This round opens the PR (merge stays a human/CI step); opt-in auto-merge is a planned
|
||||
follow-up.
|
||||
|
||||
## API Reference Summary
|
||||
|
||||
| Endpoint | Method | Phase | Notes |
|
||||
|
||||
@ -1295,3 +1295,33 @@ describe('fleet coordinator — DAG submitChildren cycle detection', () => {
|
||||
expect(result.parent.stage).toBe('blocked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet coordinator — PR target + deliverable (§PR mode)', () => {
|
||||
beforeEach(() => setProvider(new MemoryDatastoreProvider()));
|
||||
afterEach(() => _resetDatastoreProvider());
|
||||
|
||||
it('submit records repo/baseBranch on the job', async () => {
|
||||
await coord.submitJob(
|
||||
PID,
|
||||
input({ idempotencyKey: 'pr-1', repo: 'acme/widgets', baseBranch: 'develop' })
|
||||
);
|
||||
const claim = await coord.claimNextJob(factory());
|
||||
expect(claim!.job.repo).toBe('acme/widgets');
|
||||
expect(claim!.job.baseBranch).toBe('develop');
|
||||
});
|
||||
|
||||
it('release report records prUrl + branch on the run', async () => {
|
||||
await coord.submitJob(PID, input({ idempotencyKey: 'pr-2', repo: 'acme/widgets' }));
|
||||
const claim = await coord.claimNextJob(factory());
|
||||
const c = claim!.job;
|
||||
const rel = await coord.releaseLease(c.id, PID, c.leaseEpoch, 'review', {
|
||||
result: 'shipped',
|
||||
prUrl: 'https://github.com/acme/widgets/pull/42',
|
||||
branch: 'aq/job/pr-2',
|
||||
});
|
||||
expect(rel.ok).toBe(true);
|
||||
const runs = await repo.listRunsByJob(c.id);
|
||||
expect(runs[0]!.prUrl).toBe('https://github.com/acme/widgets/pull/42');
|
||||
expect(runs[0]!.branch).toBe('aq/job/pr-2');
|
||||
});
|
||||
});
|
||||
|
||||
@ -212,6 +212,8 @@ export async function submitJob(productId: string, input: SubmitJobInput): Promi
|
||||
kind: input.kind,
|
||||
parentId: input.parentId,
|
||||
trackerItemId: input.trackerItemId,
|
||||
repo: input.repo,
|
||||
baseBranch: input.baseBranch,
|
||||
attempts: 0,
|
||||
leaseEpoch: 0,
|
||||
rev: 0,
|
||||
@ -886,7 +888,12 @@ export async function releaseLease(
|
||||
productId: string,
|
||||
leaseEpoch: number,
|
||||
stage?: FleetStage,
|
||||
report?: { insights?: FleetRunDoc['insights']; result?: FleetRunDoc['result'] }
|
||||
report?: {
|
||||
insights?: FleetRunDoc['insights'];
|
||||
result?: FleetRunDoc['result'];
|
||||
prUrl?: string;
|
||||
branch?: string;
|
||||
}
|
||||
): Promise<FenceResult<FleetLeaseDoc>> {
|
||||
const job = await repo.getJob(jobId, productId);
|
||||
if (!job) return { ok: false, reason: 'not_found' };
|
||||
@ -896,12 +903,14 @@ export async function releaseLease(
|
||||
const res = await repo.revUpdateLease(jobId, lease.rev, { status: 'released' });
|
||||
if (!res.ok) return { ok: false, reason: 'conflict' };
|
||||
if (stage) await repo.revUpdateJob(jobId, productId, job.rev, { stage });
|
||||
// Record the factory's reported cost/token metrics + outcome on the current run.
|
||||
if (report?.insights || report?.result) {
|
||||
// Record the factory's reported metrics + outcome + PR deliverable on the run.
|
||||
if (report?.insights || report?.result || report?.prUrl || report?.branch) {
|
||||
const runId = `${jobId}:run:${job.attempts}`;
|
||||
await repo.updateRun(runId, jobId, {
|
||||
...(report.insights ? { insights: report.insights } : {}),
|
||||
...(report.result ? { result: report.result } : {}),
|
||||
...(report.prUrl ? { prUrl: report.prUrl } : {}),
|
||||
...(report.branch ? { branch: report.branch } : {}),
|
||||
endedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -261,6 +261,8 @@ export async function fleetRoutes(app: FastifyInstance) {
|
||||
const res = await coordinator.releaseLease(id, pid, parsed.data.leaseEpoch, parsed.data.stage, {
|
||||
insights: parsed.data.insights,
|
||||
result: parsed.data.result,
|
||||
prUrl: parsed.data.prUrl,
|
||||
branch: parsed.data.branch,
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.reason === 'not_found') throw new NotFoundError('Job or lease not found');
|
||||
|
||||
@ -177,6 +177,14 @@ export const FleetJobDocSchema = z.object({
|
||||
kind: z.enum(JOB_KINDS).default('leaf'),
|
||||
parentId: z.string().optional(),
|
||||
trackerItemId: z.string().optional(),
|
||||
/**
|
||||
* Optional PR target: the repo (e.g. `owner/name` or a clone URL) and base
|
||||
* branch the factory should open a pull request against (§PR mode). When set
|
||||
* and the factory runs with PR mode on, its work is committed to a job branch,
|
||||
* pushed, and a PR is opened; the PR URL is recorded on the run.
|
||||
*/
|
||||
repo: z.string().optional(),
|
||||
baseBranch: z.string().optional(),
|
||||
checkpoint: CheckpointSchema.optional(),
|
||||
attempts: z.number().int().nonnegative().default(0),
|
||||
leaseEpoch: z.number().int().nonnegative().default(0),
|
||||
@ -214,6 +222,9 @@ export const FleetRunDocSchema = z.object({
|
||||
verifyResult: z.enum(['pass', 'fail']).optional(),
|
||||
result: z.enum(RUN_RESULTS).optional(),
|
||||
insights: InsightsSchema.default({}),
|
||||
/** PR deliverable produced by this run (§PR mode). */
|
||||
prUrl: z.string().optional(),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
export type FleetRunDoc = z.infer<typeof FleetRunDocSchema>;
|
||||
|
||||
@ -338,6 +349,9 @@ export const SubmitJobSchema = z.object({
|
||||
kind: z.enum(JOB_KINDS).default('leaf'),
|
||||
parentId: z.string().optional(),
|
||||
trackerItemId: z.string().optional(),
|
||||
/** Optional PR target (§PR mode): repo (`owner/name` or clone URL) + base branch. */
|
||||
repo: z.string().optional(),
|
||||
baseBranch: z.string().optional(),
|
||||
/** Phase 3 DAG: inline children to create atomically with the parent. */
|
||||
children: z
|
||||
.array(
|
||||
@ -412,6 +426,9 @@ export const ReleaseLeaseSchema = z.object({
|
||||
// releases the lease at the end of a work unit. Recorded on the current run.
|
||||
insights: InsightsSchema.optional(),
|
||||
result: z.enum(RUN_RESULTS).optional(),
|
||||
// PR deliverable (§PR mode): the pull request the run opened + its branch.
|
||||
prUrl: z.string().optional(),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
export type ReleaseLeaseInput = z.infer<typeof ReleaseLeaseSchema>;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user