feat(fleet): draft jobs + editable prompt (save-as-draft, submit, lock on pickup)

Backend (platform-service):
- New `draft` stage (not claimable; scheduler only takes queued/blocked).
- submitJob accepts `draft: true` → parks a new/superseded job as a draft.
- updateDraft(): edit prompt/config in place while draft/queued/blocked;
  recomputes contentHash; rejected (conflict) once picked up (assigned+).
- submitDraft(): promote draft → queued (or blocked on unmet deps); idempotent.
- Routes: PATCH /fleet/jobs/:id/draft, POST /fleet/jobs/:id/submit.
- tracker-bridge: map draft → item status `open`. Tests + FLEET_STAGES updated.

Frontend (tracker-web):
- New-Job form: add "Save as draft" alongside "Submit".
- Job detail: edit the prompt + Save while draft/queued/blocked, "Submit" a
  draft, and lock it read-only once a factory picks it up.

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 00:44:10 -07:00
parent cb4f7a7606
commit b7df779e1d
9 changed files with 398 additions and 74 deletions

View File

@ -16,6 +16,8 @@ import {
getJobExplain,
patchJob,
operatorAction,
updateDraft,
submitDraft,
requestReview,
submitReview,
subscribeJobEvents,
@ -256,7 +258,7 @@ export default function FleetJobDetailPage() {
</section>
{/* Prompt (the job body) + PR/target config */}
<PromptCard job={job} />
<PromptCard job={job} onChanged={refresh} />
{/* Review gate (multi-reviewer human gate) */}
{job.stage === 'review' && (
@ -502,11 +504,48 @@ function MetaCard({ label, value }: { label: string; value: string }) {
/**
* The job's prompt (verbatim `bodyMd`) + its PR/target config. The prompt is only
* mutable before a factory picks the job up; once it leaves `queued`/`draft` it is
* locked (a worker may already be acting on it) so it renders read-only.
* mutable before a factory picks the job up (stage `draft`/`queued`/`blocked`):
* it can be edited + saved in place, and a `draft` can be submitted from here.
* Once a factory claims it (assigned+) the prompt is locked and renders read-only.
*/
function PromptCard({ job }: { job: FleetJob }) {
const locked = job.stage !== 'queued' && job.stage !== 'draft';
function PromptCard({ job, onChanged }: { job: FleetJob; onChanged: () => void | Promise<void> }) {
const editable = job.stage === 'draft' || job.stage === 'queued' || job.stage === 'blocked';
const [editing, setEditing] = useState(false);
const [draftBody, setDraftBody] = useState(job.bodyMd ?? '');
const [busy, setBusy] = useState<null | 'save' | 'submit'>(null);
const [err, setErr] = useState<string | null>(null);
const save = async () => {
if (!draftBody.trim()) {
setErr('Prompt cannot be empty.');
return;
}
setBusy('save');
setErr(null);
try {
await updateDraft(job.id, { bodyMd: draftBody.trim() });
setEditing(false);
await onChanged();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Save failed.');
} finally {
setBusy(null);
}
};
const submit = async () => {
setBusy('submit');
setErr(null);
try {
await submitDraft(job.id);
await onChanged();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Submit failed.');
} finally {
setBusy(null);
}
};
const cfg: Array<[string, string | undefined]> = [
['Repo', job.repo],
['Base branch', job.repo ? (job.baseBranch ?? 'main') : undefined],
@ -517,31 +556,91 @@ function PromptCard({ job }: { job: FleetJob }) {
['Idempotency key', job.idempotencyKey],
];
const shown = cfg.filter(([, v]) => v != null && v !== '');
return (
<section className="space-y-3" aria-label="Prompt and target">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Prompt</h2>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
locked
? 'bg-muted text-muted-foreground'
: 'bg-blue-500/15 text-blue-700 dark:text-blue-300'
editable
? 'bg-blue-500/15 text-blue-700 dark:text-blue-300'
: 'bg-muted text-muted-foreground'
}`}
title={
locked
? 'The job has been picked up — its prompt is locked.'
: 'Still queued — not yet picked up by a factory.'
editable
? 'Not yet picked up — the prompt can still be edited.'
: 'The job has been picked up — its prompt is locked.'
}
>
{locked ? 'read-only — picked up' : 'queued — not yet picked up'}
{editable
? job.stage === 'draft'
? 'draft — editable'
: 'editable until picked up'
: 'read-only — picked up'}
</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"
>
Edit
</Button>
)}
{job.stage === 'draft' && !editing && (
<Button onClick={submit} disabled={busy !== null} aria-label="Submit this draft">
{busy === 'submit' ? 'Submitting…' : 'Submit'}
</Button>
)}
</div>
</div>
<pre
className="max-h-96 overflow-auto whitespace-pre-wrap rounded-lg border bg-muted/30 p-3 text-sm font-mono"
aria-label="Job prompt body"
>
{job.bodyMd?.trim() ? job.bodyMd : 'No prompt body.'}
</pre>
{editing ? (
<div className="space-y-2">
<textarea
value={draftBody}
onChange={e => setDraftBody(e.target.value)}
rows={10}
className="w-full rounded-lg border bg-background p-3 text-sm font-mono"
aria-label="Edit job prompt"
/>
<div className="flex items-center gap-2">
<Button onClick={save} disabled={busy !== null} aria-label="Save prompt">
{busy === 'save' ? 'Saving…' : 'Save'}
</Button>
<Button
variant="secondary"
onClick={() => {
setEditing(false);
setErr(null);
}}
disabled={busy !== null}
aria-label="Cancel editing"
>
Cancel
</Button>
</div>
</div>
) : (
<pre
className="max-h-96 overflow-auto whitespace-pre-wrap rounded-lg border bg-muted/30 p-3 text-sm font-mono"
aria-label="Job prompt body"
>
{job.bodyMd?.trim() ? job.bodyMd : 'No prompt body.'}
</pre>
)}
{err && (
<p className="text-sm text-destructive" role="status">
{err}
</p>
)}
{shown.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{shown.map(([k, v]) => (

View File

@ -142,58 +142,62 @@ export default function FleetJobsPage() {
})
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
const handleSubmit = useCallback(async () => {
if (!body.trim()) {
setSubmitMsg({ ok: false, text: 'Job body is required.' });
return;
}
setSubmitting(true);
setSubmitMsg(null);
try {
const capabilities = caps
.split(',')
.map(c => c.trim())
.filter(Boolean);
const factory = FLEET_FACTORIES.find(f => f.id === factoryId) ?? FLEET_FACTORIES[0];
const { job } = await submitJob(
{
idempotencyKey: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
bodyMd: body.trim(),
priority,
capabilities,
...(repo
? {
repo,
baseBranch: FLEET_BASE_BRANCH,
...(verifyCmd.trim() ? { verify: verifyCmd.trim() } : {}),
...(autoMerge ? { autoMerge: true } : {}),
}
: {}),
},
factory.productId
);
// Route the dashboard view to the factory's product so the job is visible.
if (typeof window !== 'undefined') {
localStorage.setItem('tracker_selected_product', factory.productId);
const handleSubmit = useCallback(
async (asDraft = false) => {
if (!body.trim()) {
setSubmitMsg({ ok: false, text: 'Job body is required.' });
return;
}
setSubmitMsg({
ok: true,
text:
`Submitted ${job.id} to ${factory.id} (${factory.productId}, stage: ${job.stage})` +
(repo
? ` — PR mode: ${repo}@${FLEET_BASE_BRANCH}${verifyCmd.trim() ? ' +verify' : ''}${autoMerge ? ' +auto-merge' : ''}`
: ' — no PR (plain job)') +
'.',
});
setBody('');
await refresh();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Submit failed.';
setSubmitMsg({ ok: false, text: msg });
} finally {
setSubmitting(false);
}
}, [body, caps, priority, repo, verifyCmd, autoMerge, factoryId, refresh]);
setSubmitting(true);
setSubmitMsg(null);
try {
const capabilities = caps
.split(',')
.map(c => c.trim())
.filter(Boolean);
const factory = FLEET_FACTORIES.find(f => f.id === factoryId) ?? FLEET_FACTORIES[0];
const { job } = await submitJob(
{
idempotencyKey: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
bodyMd: body.trim(),
priority,
capabilities,
...(asDraft ? { draft: true } : {}),
...(repo
? {
repo,
baseBranch: FLEET_BASE_BRANCH,
...(verifyCmd.trim() ? { verify: verifyCmd.trim() } : {}),
...(autoMerge ? { autoMerge: true } : {}),
}
: {}),
},
factory.productId
);
// Route the dashboard view to the factory's product so the job is visible.
if (typeof window !== 'undefined') {
localStorage.setItem('tracker_selected_product', factory.productId);
}
setSubmitMsg({
ok: true,
text:
`${asDraft ? 'Saved draft' : 'Submitted'} ${job.id} to ${factory.id} (${factory.productId}, stage: ${job.stage})` +
(repo
? ` — PR mode: ${repo}@${FLEET_BASE_BRANCH}${verifyCmd.trim() ? ' +verify' : ''}${autoMerge ? ' +auto-merge' : ''}`
: ' — no PR (plain job)') +
(asDraft ? ' — open it to edit + submit.' : '.'),
});
setBody('');
await refresh();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Submit failed.';
setSubmitMsg({ ok: false, text: msg });
} finally {
setSubmitting(false);
}
},
[body, caps, priority, repo, verifyCmd, autoMerge, factoryId, refresh]
);
return (
<div className="p-6 space-y-6">
@ -323,8 +327,20 @@ export default function FleetJobsPage() {
Auto-merge PR
</label>
)}
<Button onClick={handleSubmit} disabled={submitting} aria-label="Submit new job">
{submitting ? 'Submitting…' : 'Submit Job'}
<Button
variant="secondary"
onClick={() => handleSubmit(true)}
disabled={submitting}
aria-label="Save as draft (not submitted)"
>
{submitting ? 'Saving…' : 'Save as draft'}
</Button>
<Button
onClick={() => handleSubmit(false)}
disabled={submitting}
aria-label="Submit new job"
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
{submitMsg && (

View File

@ -214,6 +214,8 @@ export interface SubmitJobBody {
/** PR mode: verify command run in the checkout before the PR opens; auto-merge the PR. */
verify?: string;
autoMerge?: boolean;
/** Save as a non-claimable, editable draft (stage `draft`) instead of queued. */
draft?: boolean;
}
/** Submit a new fleet job. Optionally target a specific product (factory's product),
@ -254,6 +256,27 @@ export async function operatorAction(id: string, action: OperatorAction): Promis
return apiFetch(`/jobs/${id}/actions/${action}`, { method: 'POST' });
}
/** Editable fields on a not-yet-picked-up job (draft/queued/blocked). */
export interface UpdateDraftBody {
bodyMd?: string;
priority?: 'critical' | 'high' | 'medium' | 'low';
capabilities?: string[];
repo?: string;
baseBranch?: string;
verify?: string;
autoMerge?: boolean;
}
/** Edit a job's prompt/config in place — only valid while draft/queued/blocked. */
export async function updateDraft(id: string, body: UpdateDraftBody): Promise<FleetJob> {
return apiFetch(`/jobs/${id}/draft`, { method: 'PATCH', body: JSON.stringify(body) });
}
/** Submit a draft → queued (or blocked if it has unmet deps). Idempotent. */
export async function submitDraft(id: string): Promise<FleetJob> {
return apiFetch(`/jobs/${id}/submit`, { method: 'POST' });
}
// ── Multi-reviewer human gate ─────────────────────────────────────────────────
export interface ReviewPolicy {

View File

@ -1325,3 +1325,67 @@ describe('fleet coordinator — PR target + deliverable (§PR mode)', () => {
expect(runs[0]!.branch).toBe('aq/job/pr-2');
});
});
describe('fleet coordinator — draft lifecycle (save / edit / submit)', () => {
beforeEach(() => setProvider(new MemoryDatastoreProvider()));
afterEach(() => _resetDatastoreProvider());
it('submit with draft:true creates a draft that is NOT claimable', async () => {
const { job, outcome } = await coord.submitJob(PID, input({ draft: true }));
expect(outcome).toBe('created');
expect(job.stage).toBe('draft');
// a factory must not be able to claim a draft
const claim = await coord.claimNextJob(factory());
expect(claim).toBeNull();
});
it('updateDraft edits the prompt + config while draft (content hash changes)', async () => {
const { job } = await coord.submitJob(PID, input({ draft: true }));
const before = (await repo.getJob(job.id, PID))!.contentHash;
const res = await coord.updateDraft(job.id, PID, {
bodyMd: '# edited prompt',
repo: 'learning_ai_notes',
autoMerge: true,
priority: 'high',
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.doc.bodyMd).toBe('# edited prompt');
expect(res.doc.repo).toBe('learning_ai_notes');
expect(res.doc.autoMerge).toBe(true);
expect(res.doc.priority).toBe('high');
expect(res.doc.contentHash).not.toBe(before);
expect(res.doc.stage).toBe('draft'); // editing doesn't submit it
}
});
it('submitDraft promotes draft → queued, then it is claimable', async () => {
const { job } = await coord.submitJob(PID, input({ draft: true }));
const res = await coord.submitDraft(job.id, PID);
expect(res.ok).toBe(true);
if (res.ok) expect(res.doc.stage).toBe('queued');
const claim = await coord.claimNextJob(factory());
expect(claim?.job.id).toBe(job.id);
});
it('submitDraft is idempotent on an already-submitted job', async () => {
const { job } = await coord.submitJob(PID, input()); // queued, not a draft
const res = await coord.submitDraft(job.id, PID);
expect(res.ok).toBe(true);
if (res.ok) expect(res.doc.stage).toBe('queued');
});
it('updateDraft is rejected once the job is picked up (assigned+)', async () => {
const { job } = await coord.submitJob(PID, input());
await coord.claimNextJob(factory()); // job → assigned
const res = await coord.updateDraft(job.id, PID, { bodyMd: '# too late' });
expect(res.ok).toBe(false);
if (!res.ok) expect(res.reason).toBe('conflict');
});
it('updateDraft on a missing job returns not_found', async () => {
const res = await coord.updateDraft('nope', PID, { bodyMd: 'x' });
expect(res.ok).toBe(false);
if (!res.ok) expect(res.reason).toBe('not_found');
});
});

View File

@ -48,6 +48,7 @@ import {
type FleetBudgetDoc,
type BudgetWindow,
type SubmitJobInput,
type UpdateDraftInput,
type ReviewPolicy,
type ReviewDecision,
type ReviewDecisionKind,
@ -152,7 +153,9 @@ export async function submitJob(productId: string, input: SubmitJobInput): Promi
if (existingForKey.length > 0) {
// same key, different content
const supersedable = existingForKey.find(j => j.stage === 'queued' || j.stage === 'blocked');
const supersedable = existingForKey.find(
j => j.stage === 'draft' || j.stage === 'queued' || j.stage === 'blocked'
);
if (!supersedable) {
throw new ConflictError(
`idempotency-key '${input.idempotencyKey}' already in use by an in-flight/terminal job with different content`
@ -163,7 +166,9 @@ export async function submitJob(productId: string, input: SubmitJobInput): Promi
throw new BadRequestError('dependency cycle detected — submission rejected');
}
const refreshed = applyInputToJob(supersedable, input, hash);
const { stage } = await stageForDeps(refreshed);
// A draft resubmit stays a draft; otherwise re-derive queued/blocked from deps
// (so resubmitting a draft without the flag promotes it).
const stage = input.draft ? 'draft' : (await stageForDeps(refreshed)).stage;
const updated = await repo.updateJob(supersedable.id, productId, {
...stripIdentity(refreshed),
stage,
@ -238,6 +243,10 @@ export async function submitJob(productId: string, input: SubmitJobInput): Promi
base.blockedReason = `waiting on children: ${childKeys.join(', ')}`;
}
// Save-as-draft: a brand-new (non-composite) job can be parked as a
// non-claimable, editable draft instead of going straight to queued.
if (input.draft && children.length === 0) base.stage = 'draft';
const created = await repo.createJob(base);
// Create child jobs
@ -302,6 +311,68 @@ export async function submitJob(productId: string, input: SubmitJobInput): Promi
return { job: created, outcome: 'created' };
}
/** Stages in which a job's prompt/config may still be edited (not yet picked up). */
const EDITABLE_STAGES: readonly FleetStage[] = ['draft', 'queued', 'blocked'];
/**
* Edit a not-yet-picked-up job's prompt/config in place. Allowed only while the
* job is `draft`/`queued`/`blocked`; once a factory has claimed it (assigned+) the
* prompt is locked and this returns `invalid_state`. Recomputes contentHash so the
* idempotency/supersede logic stays consistent.
*/
export async function updateDraft(
jobId: string,
productId: string,
patch: UpdateDraftInput
): Promise<repo.RevResult<FleetJobDoc>> {
const job = await repo.getJob(jobId, productId);
if (!job) return { ok: false, reason: 'not_found' };
if (!EDITABLE_STAGES.includes(job.stage)) return { ok: false, reason: 'conflict' };
const updates: Partial<FleetJobDoc> = {};
if (patch.bodyMd !== undefined) {
updates.bodyMd = patch.bodyMd;
updates.contentHash = contentHash(patch.bodyMd);
}
if (patch.priority !== undefined) {
updates.priority = patch.priority;
updates.priorityOrder = PRIORITY_ORDER[patch.priority];
}
if (patch.capabilities !== undefined) updates.capabilities = patch.capabilities;
if (patch.repo !== undefined) updates.repo = patch.repo;
if (patch.baseBranch !== undefined) updates.baseBranch = patch.baseBranch;
if (patch.verify !== undefined) updates.verify = patch.verify;
if (patch.autoMerge !== undefined) updates.autoMerge = patch.autoMerge;
const updated = await repo.updateJob(jobId, productId, updates);
if (!updated) return { ok: false, reason: 'not_found' };
await repo.appendEvent({
jobId,
productId,
type: 'edited',
data: { fields: Object.keys(updates) },
});
return { ok: true, doc: updated };
}
/**
* Submit a draft `queued` (or `blocked` if it has unmet deps). Idempotent: a job
* already past draft is returned unchanged. Only a `draft` may be submitted.
*/
export async function submitDraft(
jobId: string,
productId: string
): Promise<repo.RevResult<FleetJobDoc>> {
const job = await repo.getJob(jobId, productId);
if (!job) return { ok: false, reason: 'not_found' };
if (job.stage !== 'draft') return { ok: true, doc: job }; // already submitted — idempotent
const { stage } = await stageForDeps(job);
const updated = await repo.updateJob(jobId, productId, { stage });
if (!updated) return { ok: false, reason: 'not_found' };
await repo.appendEvent({ jobId, productId, type: 'submitted', data: { stage, from: 'draft' } });
return { ok: true, doc: updated };
}
function applyInputToJob(job: FleetJobDoc, input: SubmitJobInput, hash: string): FleetJobDoc {
return {
...job,

View File

@ -50,6 +50,7 @@ import {
EchoJobSchema,
SubmitChildrenSchema,
UpsertBudgetSchema,
UpdateDraftSchema,
} from './types.js';
function badRequest(issues: { message: string }[]): never {
@ -163,6 +164,35 @@ export async function fleetRoutes(app: FastifyInstance) {
return res.doc;
});
// ── Edit a not-yet-picked-up job's prompt/config (draft/queued/blocked) ──
app.patch('/fleet/jobs/:id/draft', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const parsed = UpdateDraftSchema.safeParse(req.body);
if (!parsed.success) badRequest(parsed.error.issues);
const res = await coordinator.updateDraft(id, pid, parsed.data);
if (!res.ok) {
if (res.reason === 'not_found') throw new NotFoundError('Job not found');
throw new ConflictError('job already picked up — prompt is read-only');
}
return res.doc;
});
// ── Submit a draft → queued (or blocked if it has unmet deps) ──
app.post('/fleet/jobs/:id/submit', async req => {
await extractAuth(req);
const { id } = req.params as { id: string };
const pid = getRequestProductId(req);
const res = await coordinator.submitDraft(id, pid);
if (!res.ok) {
if (res.reason === 'not_found') throw new NotFoundError('Job not found');
throw new ConflictError('concurrent update conflict — retry');
}
await trackerBridge.maybeEchoOnTransition(pid, id, req.log);
return res.doc;
});
// ── Multi-reviewer human gate (§14 Phase 3) ──
app.post('/fleet/jobs/:id/review/request', async req => {
await extractAuth(req);

View File

@ -133,6 +133,9 @@ export interface EchoResult {
*/
export function stageToItemStatus(stage: FleetStage): ItemStatus {
switch (stage) {
case 'draft':
// Saved but not submitted — not started yet.
return 'open';
case 'shipped':
return 'done';
case 'failed':

View File

@ -206,6 +206,7 @@ describe('request schemas', () => {
describe('enum constants', () => {
it('stages match the agent-queue lifecycle', () => {
expect(FLEET_STAGES).toEqual([
'draft',
'queued',
'blocked',
'assigned',

View File

@ -14,8 +14,10 @@ import { z } from 'zod';
// ── Enums ───────────────────────────────────────────────────────────────────
/** Canonical job lifecycle (§11). `blocked` = unmet deps; `dead_letter` = retries exhausted. */
/** Canonical job lifecycle (§11). `draft` = saved but not yet submitted (not
* claimable, editable); `blocked` = unmet deps; `dead_letter` = retries exhausted. */
export const FLEET_STAGES = [
'draft',
'queued',
'blocked',
'assigned',
@ -359,6 +361,8 @@ export const SubmitJobSchema = z.object({
* and whether to auto-merge the PR once opened. */
verify: z.string().optional(),
autoMerge: z.boolean().optional(),
/** Save as a non-claimable, editable draft (stage `draft`) instead of `queued`. */
draft: z.boolean().optional(),
/** Phase 3 DAG: inline children to create atomically with the parent. */
children: z
.array(
@ -381,6 +385,19 @@ export const SubmitJobSchema = z.object({
});
export type SubmitJobInput = z.infer<typeof SubmitJobSchema>;
/** Edit a job's prompt/config while it is still `draft`/`queued`/`blocked`
* (i.e. not yet picked up). All fields optional only provided ones change. */
export const UpdateDraftSchema = z.object({
bodyMd: z.string().min(1).optional(),
priority: z.enum(FLEET_PRIORITIES).optional(),
capabilities: z.array(z.string()).optional(),
repo: z.string().optional(),
baseBranch: z.string().optional(),
verify: z.string().optional(),
autoMerge: z.boolean().optional(),
});
export type UpdateDraftInput = z.infer<typeof UpdateDraftSchema>;
export const ListJobsQuerySchema = z.object({
productId: z.string().optional(),
stage: z.enum(FLEET_STAGES).optional(),