forked from wrenn/wrenn
feat: async sandbox lifecycle with Redis Stream events
Replace synchronous RPC-based CP-host communication for sandbox lifecycle operations (Create, Pause, Resume, Destroy) with an async pattern. CP handlers now return 202 Accepted immediately, fire agent RPCs in background goroutines, and publish state events to a Redis Stream. A background consumer processes events as a fallback writer. Agent-side auto-pause events are pushed to the CP via HTTP callback (POST /v1/hosts/sandbox-events), keeping Redis internal to the CP. All DB status transitions use conditional updates (UpdateSandboxStatusIf, UpdateSandboxRunningIf) to prevent race conditions between concurrent operations and background goroutines. The HostMonitor reconciler is kept at 60s as a safety net, extended to handle transient statuses (starting, pausing, resuming, stopping). Frontend updated to handle 202 responses with empty bodies and render transient statuses with blue indicators.
This commit is contained in:
@ -2,6 +2,19 @@ import { auth } from '$lib/auth.svelte';
|
||||
|
||||
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
|
||||
|
||||
async function parseResponse<T>(res: Response): Promise<ApiResult<T>> {
|
||||
if (res.status === 204 || res.status === 202) {
|
||||
const text = await res.text();
|
||||
if (!text) return { ok: true, data: undefined as T };
|
||||
const data = JSON.parse(text);
|
||||
return { ok: true, data: data as T };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
|
||||
return { ok: true, data: data as T };
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
@ -13,11 +26,7 @@ export async function apiFetch<T>(method: string, path: string, body?: unknown):
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
if (res.status === 204) return { ok: true, data: undefined as T };
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
|
||||
return { ok: true, data: data as T };
|
||||
return await parseResponse<T>(res);
|
||||
} catch {
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
@ -34,11 +43,7 @@ export async function apiFetchMultipart<T>(method: string, path: string, formDat
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.status === 204) return { ok: true, data: undefined as T };
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
|
||||
return { ok: true, data: data as T };
|
||||
return await parseResponse<T>(res);
|
||||
} catch {
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
|
||||
@ -149,6 +149,8 @@
|
||||
case 'running': return 'var(--color-accent)';
|
||||
case 'paused': return 'var(--color-amber)';
|
||||
case 'error': return 'var(--color-red)';
|
||||
case 'starting': case 'resuming': case 'pausing': case 'stopping':
|
||||
return 'var(--color-blue)';
|
||||
default: return 'var(--color-text-muted)';
|
||||
}
|
||||
}
|
||||
@ -158,6 +160,8 @@
|
||||
case 'running': return 'rgba(94,140,88,0.12)';
|
||||
case 'paused': return 'rgba(212,167,60,0.12)';
|
||||
case 'error': return 'rgba(207,129,114,0.12)';
|
||||
case 'starting': case 'resuming': case 'pausing': case 'stopping':
|
||||
return 'rgba(90,159,212,0.12)';
|
||||
default: return 'rgba(255,255,255,0.05)';
|
||||
}
|
||||
}
|
||||
@ -167,6 +171,8 @@
|
||||
case 'running': return 'rgba(94,140,88,0.3)';
|
||||
case 'paused': return 'rgba(212,167,60,0.3)';
|
||||
case 'error': return 'rgba(207,129,114,0.3)';
|
||||
case 'starting': case 'resuming': case 'pausing': case 'stopping':
|
||||
return 'rgba(90,159,212,0.3)';
|
||||
default: return 'rgba(255,255,255,0.08)';
|
||||
}
|
||||
}
|
||||
@ -418,7 +424,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredCapsules as capsule, i (capsule.id)}
|
||||
{@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : capsule.status === 'error' ? 'bg-[var(--color-red)]' : 'bg-[var(--color-text-muted)]'}
|
||||
{@const isTransient = ['starting', 'resuming', 'pausing', 'stopping'].includes(capsule.status)}
|
||||
{@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : capsule.status === 'error' ? 'bg-[var(--color-red)]' : isTransient ? 'bg-[var(--color-blue)]' : 'bg-[var(--color-text-muted)]'}
|
||||
<div
|
||||
class="capsule-row relative grid grid-cols-[1.6fr_0.9fr_0.5fr_0.5fr_1fr_0.7fr_0.8fr] items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0 {newCapsuleId === capsule.id ? 'capsule-born' : ''}"
|
||||
style={initialAnimationDone ? '' : `animation: fadeUp 0.35s ease both; animation-delay: ${i * 40}ms`}
|
||||
@ -437,6 +444,11 @@
|
||||
<span class="inline-flex h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--color-amber)]"></span>
|
||||
{:else if capsule.status === 'error'}
|
||||
<span class="inline-flex h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--color-red)]"></span>
|
||||
{:else if isTransient}
|
||||
<span class="relative flex h-[6px] w-[6px] shrink-0">
|
||||
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-blue)]"></span>
|
||||
<span class="relative inline-flex h-[6px] w-[6px] rounded-full bg-[var(--color-blue)]"></span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--color-text-muted)]"></span>
|
||||
{/if}
|
||||
|
||||
@ -470,7 +470,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredCapsules as capsule, i (capsule.id)}
|
||||
{@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : 'bg-[var(--color-text-muted)]'}
|
||||
{@const isTransient = ['starting', 'resuming', 'pausing', 'stopping'].includes(capsule.status)}
|
||||
{@const stripeColor = capsule.status === 'running' ? 'bg-[var(--color-accent)]' : capsule.status === 'paused' ? 'bg-[var(--color-amber)]' : isTransient ? 'bg-[var(--color-blue)]' : 'bg-[var(--color-text-muted)]'}
|
||||
<div
|
||||
class="capsule-row relative grid grid-cols-[1.6fr_0.8fr_0.5fr_0.5fr_0.6fr_1fr_0.9fr] items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0 {newCapsuleId === capsule.id ? 'capsule-born' : ''}"
|
||||
style={initialAnimationDone ? '' : `animation: fadeUp 0.35s ease both; animation-delay: ${i * 40}ms`}
|
||||
@ -487,6 +488,11 @@
|
||||
</span>
|
||||
{:else if capsule.status === 'paused'}
|
||||
<span class="inline-flex h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--color-amber)]"></span>
|
||||
{:else if isTransient}
|
||||
<span class="relative flex h-[6px] w-[6px] shrink-0">
|
||||
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-blue)]"></span>
|
||||
<span class="relative inline-flex h-[6px] w-[6px] rounded-full bg-[var(--color-blue)]"></span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--color-text-muted)]"></span>
|
||||
{/if}
|
||||
@ -556,7 +562,7 @@
|
||||
openMenuId = capsule.id;
|
||||
}
|
||||
}}
|
||||
class="inline-flex items-center gap-1.5 rounded-[var(--radius-button)] border px-2.5 py-1 text-label font-semibold uppercase tracking-[0.04em] transition-colors duration-150 {capsule.status === 'running' ? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow)] text-[var(--color-accent-mid)] hover:border-[var(--color-accent)]/70 hover:text-[var(--color-accent-bright)]' : capsule.status === 'paused' ? 'border-[var(--color-amber)]/30 bg-[var(--color-amber)]/5 text-[var(--color-amber)] hover:border-[var(--color-amber)]/60' : 'border-[var(--color-border)] bg-[var(--color-bg-2)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)]'}"
|
||||
class="inline-flex items-center gap-1.5 rounded-[var(--radius-button)] border px-2.5 py-1 text-label font-semibold uppercase tracking-[0.04em] transition-colors duration-150 {capsule.status === 'running' ? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow)] text-[var(--color-accent-mid)] hover:border-[var(--color-accent)]/70 hover:text-[var(--color-accent-bright)]' : capsule.status === 'paused' ? 'border-[var(--color-amber)]/30 bg-[var(--color-amber)]/5 text-[var(--color-amber)] hover:border-[var(--color-amber)]/60' : isTransient ? 'border-[var(--color-blue)]/30 bg-[var(--color-blue)]/5 text-[var(--color-blue)]' : 'border-[var(--color-border)] bg-[var(--color-bg-2)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)]'}"
|
||||
>
|
||||
{capsule.status}
|
||||
<svg
|
||||
|
||||
@ -404,6 +404,8 @@
|
||||
case 'running': return 'var(--color-accent)';
|
||||
case 'paused': return 'var(--color-amber)';
|
||||
case 'error': return 'var(--color-red)';
|
||||
case 'starting': case 'resuming': case 'pausing': case 'stopping':
|
||||
return 'var(--color-blue)';
|
||||
default: return 'var(--color-text-muted)';
|
||||
}
|
||||
}
|
||||
@ -413,6 +415,8 @@
|
||||
case 'running': return 'rgba(94,140,88,0.12)';
|
||||
case 'paused': return 'rgba(212,167,60,0.12)';
|
||||
case 'error': return 'rgba(207,129,114,0.12)';
|
||||
case 'starting': case 'resuming': case 'pausing': case 'stopping':
|
||||
return 'rgba(90,159,212,0.12)';
|
||||
default: return 'rgba(255,255,255,0.05)';
|
||||
}
|
||||
}
|
||||
@ -422,6 +426,8 @@
|
||||
case 'running': return 'rgba(94,140,88,0.3)';
|
||||
case 'paused': return 'rgba(212,167,60,0.3)';
|
||||
case 'error': return 'rgba(207,129,114,0.3)';
|
||||
case 'starting': case 'resuming': case 'pausing': case 'stopping':
|
||||
return 'rgba(90,159,212,0.3)';
|
||||
default: return 'rgba(255,255,255,0.08)';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user