feat(services): sleep mode — scale-to-zero on idle, wake on traffic

Services gain sleep_enabled + sleep_idle_minutes. A 30s sweeper tracks
container NetworkIO deltas; a flat counter for the configured window
scales the service to zero (status=sleeping, workload containers removed).

While slept, a busybox wake container takes over the service's Traefik
host rule at priority 100: it serves a self-reloading "waking" page and
calls the unauthenticated idempotent GET/POST /api/v1/internal/wake/:id,
which reconciles the service back to running. Wake placeholders carry a
dedicated label (containr.wake-for) so runtime state never counts them;
the sweeper prunes them once the workload is up. The wake URL resolves
the API via its container name when containerized, else the edge
network's IPAM gateway — bare-metal installs need HOST=0.0.0.0 for the
callback.

Surfaces: PUT fields on services, POST /services/:id/sleep|wake,
CLI --sleep/--sleep-idle + services sleep|wake, 2 MCP tools (89 total),
OpenAPI paths/schema, and a Settings > Sleep card with toggle, idle
minutes, and manual sleep/wake plus a Sleeping badge.

Verified live: idle sweep scaled whoami-web to zero; the wake container
served the page on the edge network and its callback woke the service
(container recreated, status running, placeholder pruned).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-09 00:22:28 +02:00
1 parent b4baa853b2
commit dbc87bf34c
15 files changed
+957 -49

No files matched your search

+7 -1
View File
@@ -63,6 +63,8 @@ export type ServiceEntity = {
memoryReserve?: string;
staticBuildCmd?: string;
staticDir?: string;
sleepEnabled?: boolean;
sleepIdleMinutes?: number;
publicUrl?: string;
};
@@ -611,6 +613,8 @@ function normalizeService(service: RawService): ServiceEntity | null {
memoryReserve: service.memory_reserve,
staticBuildCmd: service.static_build_cmd,
staticDir: service.static_dir,
sleepEnabled: service.sleep_enabled,
sleepIdleMinutes: service.sleep_idle_minutes,
publicUrl: service.public_url,
};
}
@@ -1290,13 +1294,15 @@ export async function getServiceRuntime(serviceId: string): Promise<ServiceRunti
return normalizeRuntime(payload.runtime);
}
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart' | 'sleep' | 'wake'): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
}
export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
export const sleepService = (serviceId: string) => serviceAction(serviceId, 'sleep');
export const wakeService = (serviceId: string) => serviceAction(serviceId, 'wake');
export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {