loops-wizard: initial_burst + on_artifact_update trigger UI
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 41s
ci / rust (push) Failing after 50s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Surfaces the two new trigger fields shipped in the previous commit so
users can compose the new loop patterns without editing raw jsonb.

Step 4 (Triggers) now has five toggles instead of three:
1. **Run at create-time (initial burst)** — number input, defaults to 1
   so every new loop runs at least once. Toggle off = classic behavior
   (no auto-fire).
2. Cron schedule (existing)
3. On previous completion (existing) — pairs with initial_burst > 1
   for a back-to-back burst chain, and with cron for continuous
   iteration between schedule ticks.
4. **On new research artifact** — visible always; auto-disabled with
   an explanatory label when no source topic is bound. Coalesced
   server-side against in-flight runs so rapid revisions don't spawn
   duplicates.
5. Webhook (existing)

Body wiring:
- Sends initial_burst > 0 → { initial_burst: N } in triggers jsonb.
- Sends on_artifact_update only when a source topic is bound (safety
  belt matching the backend's fan-out filter).

Step gate:
- Advancing from step 4 now requires ANY one of the five triggers
  (was just the three legacy toggles). The initial_burst default of 1
  means users can happy-path through this step without deliberate
  action, which is the intended nudge — every loop should have at
  least one path to run.

Read/write parity:
- readInitialTriggers grows two fields so edit-mode reflects existing
  loops' triggers correctly.

Next commit lands ResearchWizard's "When should this run?" step +
optional paired coding loop, and after that the kind='research'
dispatch path.
This commit is contained in:
Omar Sobh
2026-07-09 22:51:43 -07:00
parent 4ada5557f2
commit 5aa2de7f30
@@ -103,6 +103,18 @@ export function LoopsWizard({
const [webhookEnabled, setWebhookEnabled] = useState( const [webhookEnabled, setWebhookEnabled] = useState(
!!initialTriggers.webhook_enabled, !!initialTriggers.webhook_enabled,
); );
// NEW — initial_burst: how many iterations to fire back-to-back on
// create. Default 1 so every loop runs at least once (D1).
const [initialBurst, setInitialBurst] = useState<number>(
typeof initialTriggers.initial_burst === "number"
? initialTriggers.initial_burst
: 1,
);
// NEW — on_artifact_update: wake up when the source topic gets a new
// outcome. Only meaningful when a source_research_topic is bound.
const [onArtifactUpdate, setOnArtifactUpdate] = useState<boolean>(
!!initialTriggers.on_artifact_update,
);
const [repeatKind, setRepeatKind] = useState<"infinite" | "iters" | "until">( const [repeatKind, setRepeatKind] = useState<"infinite" | "iters" | "until">(
initialRepeat.kind, initialRepeat.kind,
); );
@@ -211,7 +223,12 @@ export function LoopsWizard({
(step === 1 && title.trim().length > 0) || (step === 1 && title.trim().length > 0) ||
step === 2 || step === 2 ||
(step === 3 && task.trim().length > 0 && graphReady) || (step === 3 && task.trim().length > 0 && graphReady) ||
(step === 4 && (cronEnabled || onCompletion || webhookEnabled)) || (step === 4 &&
(cronEnabled ||
onCompletion ||
webhookEnabled ||
initialBurst > 0 ||
(onArtifactUpdate && !!sourceTopicId))) ||
step === 5 || step === 5 ||
step === 6; step === 6;
@@ -262,6 +279,10 @@ export function LoopsWizard({
...(cronEnabled ? { cron: cron.trim() } : {}), ...(cronEnabled ? { cron: cron.trim() } : {}),
...(onCompletion ? { on_completion: true } : {}), ...(onCompletion ? { on_completion: true } : {}),
...(webhookEnabled ? { webhook_enabled: true } : {}), ...(webhookEnabled ? { webhook_enabled: true } : {}),
...(initialBurst > 0 ? { initial_burst: initialBurst } : {}),
...(onArtifactUpdate && sourceTopicId
? { on_artifact_update: true }
: {}),
}, },
repeat_policy, repeat_policy,
...(repo ? { repo } : {}), ...(repo ? { repo } : {}),
@@ -481,6 +502,28 @@ export function LoopsWizard({
{!noAgents && step === 4 && ( {!noAgents && step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={labelStyle}>Triggers (pick any combination)</span> <span style={labelStyle}>Triggers (pick any combination)</span>
<TriggerToggle
on={initialBurst > 0}
onToggle={(on) => setInitialBurst(on ? Math.max(1, initialBurst) : 0)}
label="Run at create-time (initial burst)"
hint="Fire N iterations back-to-back when the loop is created. Default 1 = runs at least once."
>
{initialBurst > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="number"
min={1}
max={100}
value={initialBurst}
onChange={(e) => setInitialBurst(Math.max(1, Number(e.target.value) || 1))}
style={{ ...fieldStyle, width: 90, fontFamily: mono }}
/>
<span style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
iteration{initialBurst === 1 ? "" : "s"} back-to-back
</span>
</div>
)}
</TriggerToggle>
<TriggerToggle <TriggerToggle
on={cronEnabled} on={cronEnabled}
onToggle={setCronEnabled} onToggle={setCronEnabled}
@@ -502,6 +545,16 @@ export function LoopsWizard({
label="On previous completion" label="On previous completion"
hint="Enqueue the next iteration as soon as the previous one emits run_completed." hint="Enqueue the next iteration as soon as the previous one emits run_completed."
/> />
<TriggerToggle
on={onArtifactUpdate && !!sourceTopicId}
onToggle={(on) => setOnArtifactUpdate(on)}
label={
sourceTopicId
? "On new research artifact"
: "On new research artifact (bind a source topic first)"
}
hint="Wake up whenever the bound research topic writes a fresh outcome version. Coalesced with any in-flight run."
/>
<TriggerToggle <TriggerToggle
on={webhookEnabled} on={webhookEnabled}
onToggle={setWebhookEnabled} onToggle={setWebhookEnabled}
@@ -1041,6 +1094,8 @@ function readInitialTriggers(t: unknown): {
cron?: string; cron?: string;
on_completion?: boolean; on_completion?: boolean;
webhook_enabled?: boolean; webhook_enabled?: boolean;
initial_burst?: number;
on_artifact_update?: boolean;
} { } {
if (!t || typeof t !== "object") return {}; if (!t || typeof t !== "object") return {};
const o = t as Record<string, unknown>; const o = t as Record<string, unknown>;
@@ -1048,6 +1103,9 @@ function readInitialTriggers(t: unknown): {
cron: typeof o.cron === "string" ? o.cron : undefined, cron: typeof o.cron === "string" ? o.cron : undefined,
on_completion: !!o.on_completion, on_completion: !!o.on_completion,
webhook_enabled: !!o.webhook_enabled, webhook_enabled: !!o.webhook_enabled,
initial_burst:
typeof o.initial_burst === "number" ? o.initial_burst : undefined,
on_artifact_update: !!o.on_artifact_update,
}; };
} }