Skip to content

Commit 5c5eef7

Browse files
authored
feat(kiloclaw) Admin instance actions scheduler (#3000)
* feat(kiloclaw): scheduling primitive + scheduled_restart * feat(kiloclaw): version_change scheduled action + scheduler UI * feat(kiloclaw): schedule for later in instance UI + bulk dialog * fix(kiloclaw): scheduler review fixes + design.md alignment * feat(kiloclaw): instance-aware scheduling UX + concurrency guard * fix(kiloclaw): scheduler review fixes (promotion + datetime guards + concurrency cap) * chore: revert .gitignore * bot-review concurrency fixes (claim-before-dispatch, atomic counters, serializable schedule * fix(kiloclaw): scheduler review fixes from pandemicsyn * fix(kiloclaw): promotion sweep treats running targets as unresolved
1 parent 0c8c06b commit 5c5eef7

21 files changed

Lines changed: 22534 additions & 50 deletions

apps/web/src/app/admin/components/KiloclawDashboard.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RegionsTab } from './KiloclawRegions/KiloclawRegionsPage';
1010
import { CliRunsTab } from './KiloclawCliRuns/KiloclawCliRunsTab';
1111
import { KiloclawSecurityAdvisorContentTab } from './KiloclawSecurityAdvisorContent/KiloclawSecurityAdvisorContentTab';
1212
import { KiloclawProvidersTab } from './KiloclawProvidersTab';
13+
import { KiloclawSchedulerTab } from './KiloclawScheduler/KiloclawSchedulerTab';
1314

1415
const VALID_TABS: readonly string[] = [
1516
'instances',
@@ -20,6 +21,7 @@ const VALID_TABS: readonly string[] = [
2021
'providers',
2122
'cli-runs',
2223
'shell-security-content',
24+
'scheduler',
2325
];
2426
type Tab =
2527
| 'instances'
@@ -29,7 +31,8 @@ type Tab =
2931
| 'regions'
3032
| 'providers'
3133
| 'cli-runs'
32-
| 'shell-security-content';
34+
| 'shell-security-content'
35+
| 'scheduler';
3336
const isValidTab = (value: string | null): value is Tab =>
3437
value !== null && VALID_TABS.includes(value);
3538

@@ -116,6 +119,9 @@ export function KiloclawDashboard() {
116119
<TabsTrigger value="shell-security-content" className={tabTriggerClass}>
117120
ShellSecurity Content
118121
</TabsTrigger>
122+
<TabsTrigger value="scheduler" className={tabTriggerClass}>
123+
Scheduler
124+
</TabsTrigger>
119125
</TabsList>
120126
<TabsContent value="instances" className="mt-4">
121127
<KiloclawInstancesPage />
@@ -141,6 +147,9 @@ export function KiloclawDashboard() {
141147
<TabsContent value="shell-security-content" className="mt-4">
142148
<KiloclawSecurityAdvisorContentTab />
143149
</TabsContent>
150+
<TabsContent value="scheduler" className="mt-4">
151+
<KiloclawSchedulerTab />
152+
</TabsContent>
144153
</Tabs>
145154
</div>
146155
);

apps/web/src/app/admin/components/KiloclawInstances/BulkChangeVersionDialog.tsx

Lines changed: 107 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ import {
2323
SelectValue,
2424
} from '@/components/ui/select';
2525
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
26+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
2627
import { AlertTriangle, ChevronDown, ChevronRight, Copy } from 'lucide-react';
28+
import { toast } from 'sonner';
2729
import type { inferRouterOutputs } from '@trpc/server';
2830
import type { RootRouter } from '@/routers/root-router';
2931
import type { AdminKiloclawInstance } from '@/routers/admin-kiloclaw-instances-router';
32+
import { defaultScheduledAt } from '@/lib/kiloclaw/scheduled-action-form';
3033

3134
type RouterOutputs = inferRouterOutputs<RootRouter>;
3235
type ListVersionsItem = RouterOutputs['admin']['kiloclawVersions']['listVersions']['items'][number];
@@ -69,6 +72,8 @@ export function BulkChangeVersionDialog({
6972
const [appliedSectionOpen, setAppliedSectionOpen] = useState(true);
7073
const [skippedSectionOpen, setSkippedSectionOpen] = useState(true);
7174
const [failedSectionOpen, setFailedSectionOpen] = useState(true);
75+
const [mode, setMode] = useState<'now' | 'scheduled'>('now');
76+
const [scheduledAt, setScheduledAt] = useState<string>(defaultScheduledAt);
7277

7378
// Reset form whenever the dialog reopens. Keeps state from leaking
7479
// between independent admin actions.
@@ -78,6 +83,7 @@ export function BulkChangeVersionDialog({
7883
setOverridePins(false);
7984
setConfirmInput('');
8085
setResult(null);
86+
setMode('now');
8187
}
8288
}, [open]);
8389

@@ -163,21 +169,62 @@ export function BulkChangeVersionDialog({
163169
})
164170
);
165171

172+
// Scheduled bulk path. One scheduleAction call covers all selected
173+
// instances (parent + N targets). The schedule shows up in the
174+
// Scheduler tab; no per-instance result partition (that happens at
175+
// apply time inside each DO).
176+
const bulkSchedule = useMutation(
177+
trpc.admin.kiloclawInstances.scheduleAction.mutationOptions({
178+
onSuccess: () => {
179+
toast.success(
180+
`Scheduled version change on ${selectedIds.length} ${selectedIds.length === 1 ? 'instance' : 'instances'}`
181+
);
182+
void queryClient.invalidateQueries({
183+
queryKey: trpc.admin.kiloclawInstances.listScheduledActions.queryKey(),
184+
});
185+
onApplied();
186+
onOpenChange(false);
187+
},
188+
onError: err => {
189+
toast.error(`Failed to schedule: ${err.message}`);
190+
},
191+
})
192+
);
193+
166194
const overrideRequiresConfirm = overridePins;
167195
const confirmMatches = !overrideRequiresConfirm || confirmInput === CONFIRM_TOKEN;
168-
const canApply = targetTag !== '' && confirmMatches && !bulkChange.isPending && result === null;
196+
const isPending = bulkChange.isPending || bulkSchedule.isPending;
197+
// In schedule mode also require a non-empty datetime — the input has
198+
// `required` for browser validation but we still guard here so a
199+
// programmatic submit can't fall into `new Date("")` → RangeError.
200+
const scheduleDateValid = mode !== 'scheduled' || scheduledAt !== '';
201+
const canApply =
202+
targetTag !== '' && confirmMatches && scheduleDateValid && !isPending && result === null;
169203

170204
const onApply = () => {
171205
if (!targetTag) return;
172-
bulkChange.mutate({
206+
if (mode === 'now') {
207+
bulkChange.mutate({
208+
instanceIds: selectedIds,
209+
imageTag: targetTag,
210+
overridePins,
211+
});
212+
return;
213+
}
214+
// Scheduled path — convert local datetime-local to UTC ISO.
215+
const local = new Date(scheduledAt);
216+
if (Number.isNaN(local.getTime())) return;
217+
bulkSchedule.mutate({
218+
actionType: 'version_change',
173219
instanceIds: selectedIds,
174220
imageTag: targetTag,
175221
overridePins,
222+
scheduledAt: local.toISOString(),
176223
});
177224
};
178225

179226
const handleClose = (next: boolean) => {
180-
if (bulkChange.isPending) return; // don't close mid-flight
227+
if (isPending) return; // don't close mid-flight
181228
onOpenChange(next);
182229
};
183230

@@ -217,15 +264,52 @@ export function BulkChangeVersionDialog({
217264
</DialogDescription>
218265
</DialogHeader>
219266

220-
<Alert variant="destructive">
221-
<AlertTriangle className="h-4 w-4" />
222-
<AlertTitle>This runs immediately. No undo, no user notice.</AlertTitle>
223-
<AlertDescription>
224-
Every selected instance restarts now. End users get no notification, and any active
225-
session is interrupted. Confirm the selection and target version are correct before
226-
applying.
227-
</AlertDescription>
228-
</Alert>
267+
<Tabs value={mode} onValueChange={v => setMode(v as 'now' | 'scheduled')}>
268+
<TabsList className="grid w-full grid-cols-2">
269+
<TabsTrigger value="now">Apply now</TabsTrigger>
270+
<TabsTrigger value="scheduled">Schedule for later</TabsTrigger>
271+
</TabsList>
272+
<TabsContent value="now" className="mt-3">
273+
<Alert variant="destructive">
274+
<AlertTriangle className="h-4 w-4" />
275+
<AlertTitle>This runs immediately. No undo, no user notice.</AlertTitle>
276+
<AlertDescription>
277+
Every selected instance restarts now. End users get no notification, and any
278+
active session is interrupted. Confirm the selection and target version are
279+
correct before applying.
280+
</AlertDescription>
281+
</Alert>
282+
</TabsContent>
283+
<TabsContent value="scheduled" className="mt-3 space-y-2">
284+
<Alert>
285+
<AlertTriangle className="h-4 w-4" />
286+
<AlertDescription>
287+
Notifications aren't implemented yet — end users get no warning before their
288+
session is interrupted at the scheduled time. Use cautiously on customer
289+
instances until the notifications work lands.
290+
</AlertDescription>
291+
</Alert>
292+
<div className="space-y-2">
293+
<Label htmlFor="bulk-scheduled-at">Scheduled at (local time)</Label>
294+
<Input
295+
id="bulk-scheduled-at"
296+
type="datetime-local"
297+
value={scheduledAt}
298+
onChange={e => setScheduledAt(e.target.value)}
299+
disabled={isPending}
300+
// Without `required`, an admin can clear the field
301+
// and submit; new Date("") throws RangeError below.
302+
required
303+
/>
304+
</div>
305+
<p className="text-muted-foreground text-xs">
306+
Each instance fires on its next reconcile alarm tick after the scheduled time
307+
(cadence ~5 minutes for running instances). Treat as a "no earlier than" bound.
308+
Per-instance outcome (applied / skipped / failed) shows up in the Scheduler tab as
309+
the action progresses.
310+
</p>
311+
</TabsContent>
312+
</Tabs>
229313

230314
<div className="space-y-4 py-2">
231315
<div className="bg-muted/30 rounded-md border p-3 text-sm">
@@ -344,19 +428,23 @@ export function BulkChangeVersionDialog({
344428
</div>
345429

346430
<DialogFooter>
347-
<Button
348-
variant="outline"
349-
onClick={() => handleClose(false)}
350-
disabled={bulkChange.isPending}
351-
>
431+
<Button variant="outline" onClick={() => handleClose(false)} disabled={isPending}>
352432
Cancel
353433
</Button>
354434
<Button
355435
onClick={onApply}
356436
disabled={!canApply}
357-
className={overridePins ? 'bg-destructive hover:bg-destructive/90' : undefined}
437+
className={
438+
mode === 'now' && overridePins
439+
? 'bg-destructive hover:bg-destructive/90'
440+
: undefined
441+
}
358442
>
359-
{overridePins ? 'Override and change version' : 'Apply'}
443+
{mode === 'scheduled'
444+
? 'Schedule'
445+
: overridePins
446+
? 'Override and change version'
447+
: 'Apply'}
360448
</Button>
361449
</DialogFooter>
362450
</>

0 commit comments

Comments
 (0)