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:
parent
cb4f7a7606
commit
b7df779e1d
@ -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]) => (
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user