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">Duration</th>
|
||||||
<th className="pb-2 pr-4">Model</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 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -401,6 +402,21 @@ export default function FleetJobDetailPage() {
|
|||||||
'—'
|
'—'
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -30,8 +30,10 @@ export default function FleetJobsPage() {
|
|||||||
// New-job form state
|
// New-job form state
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [body, setBody] = useState('');
|
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 [caps, setCaps] = useState('build');
|
||||||
|
const [repo, setRepo] = useState('');
|
||||||
|
const [baseBranch, setBaseBranch] = useState('main');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
|
||||||
@ -72,6 +74,7 @@ export default function FleetJobsPage() {
|
|||||||
bodyMd: body.trim(),
|
bodyMd: body.trim(),
|
||||||
priority,
|
priority,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
...(repo.trim() ? { repo: repo.trim(), baseBranch: baseBranch.trim() || 'main' } : {}),
|
||||||
});
|
});
|
||||||
setSubmitMsg({ ok: true, text: `Submitted ${job.id} (stage: ${job.stage}).` });
|
setSubmitMsg({ ok: true, text: `Submitted ${job.id} (stage: ${job.stage}).` });
|
||||||
setBody('');
|
setBody('');
|
||||||
@ -82,7 +85,7 @@ export default function FleetJobsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [body, caps, priority, refresh]);
|
}, [body, caps, priority, repo, baseBranch, refresh]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@ -122,11 +125,14 @@ export default function FleetJobsPage() {
|
|||||||
<select
|
<select
|
||||||
id="job-priority"
|
id="job-priority"
|
||||||
value={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"
|
className="rounded border bg-background px-2 py-1 text-sm"
|
||||||
>
|
>
|
||||||
|
<option value="critical">critical</option>
|
||||||
<option value="high">high</option>
|
<option value="high">high</option>
|
||||||
<option value="normal">normal</option>
|
<option value="medium">medium</option>
|
||||||
<option value="low">low</option>
|
<option value="low">low</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -141,6 +147,31 @@ export default function FleetJobsPage() {
|
|||||||
className="rounded border bg-background px-2 py-1 text-sm"
|
className="rounded border bg-background px-2 py-1 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<Button onClick={handleSubmit} disabled={submitting} aria-label="Submit new job">
|
||||||
{submitting ? 'Submitting…' : 'Submit Job'}
|
{submitting ? 'Submitting…' : 'Submit Job'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -63,6 +63,8 @@ export interface FleetRun {
|
|||||||
endedAt?: string;
|
endedAt?: string;
|
||||||
result?: string;
|
result?: string;
|
||||||
insights: FleetRunInsights;
|
insights: FleetRunInsights;
|
||||||
|
prUrl?: string;
|
||||||
|
branch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetEvent {
|
export interface FleetEvent {
|
||||||
@ -195,8 +197,11 @@ export async function getJob(id: string): Promise<FleetJob | null> {
|
|||||||
export interface SubmitJobBody {
|
export interface SubmitJobBody {
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
bodyMd: string;
|
bodyMd: string;
|
||||||
priority?: 'low' | 'normal' | 'high';
|
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||||
capabilities?: string[];
|
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. */
|
/** 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
|
run's `insights.costUsd`. So the dashboard's per-run result/cost/tokens stay
|
||||||
consistent with the job stage.
|
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
|
## API Reference Summary
|
||||||
|
|
||||||
| Endpoint | Method | Phase | Notes |
|
| Endpoint | Method | Phase | Notes |
|
||||||
|
|||||||
@ -1295,3 +1295,33 @@ describe('fleet coordinator — DAG submitChildren cycle detection', () => {
|
|||||||
expect(result.parent.stage).toBe('blocked');
|
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,
|
kind: input.kind,
|
||||||
parentId: input.parentId,
|
parentId: input.parentId,
|
||||||
trackerItemId: input.trackerItemId,
|
trackerItemId: input.trackerItemId,
|
||||||
|
repo: input.repo,
|
||||||
|
baseBranch: input.baseBranch,
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
leaseEpoch: 0,
|
leaseEpoch: 0,
|
||||||
rev: 0,
|
rev: 0,
|
||||||
@ -886,7 +888,12 @@ export async function releaseLease(
|
|||||||
productId: string,
|
productId: string,
|
||||||
leaseEpoch: number,
|
leaseEpoch: number,
|
||||||
stage?: FleetStage,
|
stage?: FleetStage,
|
||||||
report?: { insights?: FleetRunDoc['insights']; result?: FleetRunDoc['result'] }
|
report?: {
|
||||||
|
insights?: FleetRunDoc['insights'];
|
||||||
|
result?: FleetRunDoc['result'];
|
||||||
|
prUrl?: string;
|
||||||
|
branch?: string;
|
||||||
|
}
|
||||||
): Promise<FenceResult<FleetLeaseDoc>> {
|
): Promise<FenceResult<FleetLeaseDoc>> {
|
||||||
const job = await repo.getJob(jobId, productId);
|
const job = await repo.getJob(jobId, productId);
|
||||||
if (!job) return { ok: false, reason: 'not_found' };
|
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' });
|
const res = await repo.revUpdateLease(jobId, lease.rev, { status: 'released' });
|
||||||
if (!res.ok) return { ok: false, reason: 'conflict' };
|
if (!res.ok) return { ok: false, reason: 'conflict' };
|
||||||
if (stage) await repo.revUpdateJob(jobId, productId, job.rev, { stage });
|
if (stage) await repo.revUpdateJob(jobId, productId, job.rev, { stage });
|
||||||
// Record the factory's reported cost/token metrics + outcome on the current run.
|
// Record the factory's reported metrics + outcome + PR deliverable on the run.
|
||||||
if (report?.insights || report?.result) {
|
if (report?.insights || report?.result || report?.prUrl || report?.branch) {
|
||||||
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 } : {}),
|
||||||
...(report.result ? { result: report.result } : {}),
|
...(report.result ? { result: report.result } : {}),
|
||||||
|
...(report.prUrl ? { prUrl: report.prUrl } : {}),
|
||||||
|
...(report.branch ? { branch: report.branch } : {}),
|
||||||
endedAt: new Date().toISOString(),
|
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, {
|
const res = await coordinator.releaseLease(id, pid, parsed.data.leaseEpoch, parsed.data.stage, {
|
||||||
insights: parsed.data.insights,
|
insights: parsed.data.insights,
|
||||||
result: parsed.data.result,
|
result: parsed.data.result,
|
||||||
|
prUrl: parsed.data.prUrl,
|
||||||
|
branch: parsed.data.branch,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.reason === 'not_found') throw new NotFoundError('Job or lease not found');
|
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'),
|
kind: z.enum(JOB_KINDS).default('leaf'),
|
||||||
parentId: z.string().optional(),
|
parentId: z.string().optional(),
|
||||||
trackerItemId: 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(),
|
checkpoint: CheckpointSchema.optional(),
|
||||||
attempts: z.number().int().nonnegative().default(0),
|
attempts: z.number().int().nonnegative().default(0),
|
||||||
leaseEpoch: 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(),
|
verifyResult: z.enum(['pass', 'fail']).optional(),
|
||||||
result: z.enum(RUN_RESULTS).optional(),
|
result: z.enum(RUN_RESULTS).optional(),
|
||||||
insights: InsightsSchema.default({}),
|
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>;
|
export type FleetRunDoc = z.infer<typeof FleetRunDocSchema>;
|
||||||
|
|
||||||
@ -338,6 +349,9 @@ export const SubmitJobSchema = z.object({
|
|||||||
kind: z.enum(JOB_KINDS).default('leaf'),
|
kind: z.enum(JOB_KINDS).default('leaf'),
|
||||||
parentId: z.string().optional(),
|
parentId: z.string().optional(),
|
||||||
trackerItemId: 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. */
|
/** Phase 3 DAG: inline children to create atomically with the parent. */
|
||||||
children: z
|
children: z
|
||||||
.array(
|
.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.
|
// releases the lease at the end of a work unit. Recorded on the current run.
|
||||||
insights: InsightsSchema.optional(),
|
insights: InsightsSchema.optional(),
|
||||||
result: z.enum(RUN_RESULTS).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>;
|
export type ReleaseLeaseInput = z.infer<typeof ReleaseLeaseSchema>;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user