mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat: add web gateway hot reload and polling state sync (#1684)
* feat(gateway): support hot reload and empty startup - extract gateway runtime into pkg/gateway - add gateway.hot_reload config with default and example values - allow starting the gateway without a default model via --allow-empty - stop treating missing enabled channels as a startup error - update related tests * feat: replace gateway SSE updates with polling-based state sync - remove gateway SSE broadcasting and event endpoint - add polling-based gateway status refresh with stopping state handling - detect when gateway restart is required after default model changes - resolve gateway health and websocket proxy targets from configured host - update gateway UI labels and add backend/frontend test coverage
This commit is contained in:
@@ -6,6 +6,7 @@ export type GatewayState =
|
||||
| "running"
|
||||
| "starting"
|
||||
| "restarting"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "error"
|
||||
| "unknown"
|
||||
@@ -24,9 +25,29 @@ const DEFAULT_GATEWAY_STATE: GatewayStoreState = {
|
||||
restartRequired: false,
|
||||
}
|
||||
|
||||
const GATEWAY_POLL_INTERVAL_MS = 2000
|
||||
const GATEWAY_TRANSIENT_POLL_INTERVAL_MS = 1000
|
||||
const GATEWAY_STOPPING_TIMEOUT_MS = 5000
|
||||
|
||||
interface RefreshGatewayStateOptions {
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
// Global atom for gateway state
|
||||
export const gatewayAtom = atom<GatewayStoreState>(DEFAULT_GATEWAY_STATE)
|
||||
|
||||
let gatewayPollingSubscribers = 0
|
||||
let gatewayPollingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let gatewayPollingRequest: Promise<void> | null = null
|
||||
let gatewayStoppingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearGatewayStoppingTimeout() {
|
||||
if (gatewayStoppingTimer !== null) {
|
||||
clearTimeout(gatewayStoppingTimer)
|
||||
gatewayStoppingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGatewayStoreState(
|
||||
prev: GatewayStoreState,
|
||||
patch: GatewayStorePatch,
|
||||
@@ -49,10 +70,38 @@ export function updateGatewayStore(
|
||||
| GatewayStorePatch
|
||||
| ((prev: GatewayStoreState) => GatewayStorePatch | GatewayStoreState),
|
||||
) {
|
||||
getDefaultStore().set(gatewayAtom, (prev) => {
|
||||
const store = getDefaultStore()
|
||||
store.set(gatewayAtom, (prev) => {
|
||||
const nextPatch = typeof patch === "function" ? patch(prev) : patch
|
||||
return normalizeGatewayStoreState(prev, nextPatch)
|
||||
})
|
||||
const nextState = store.get(gatewayAtom)
|
||||
if (nextState?.status !== "stopping") {
|
||||
clearGatewayStoppingTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
export function beginGatewayStoppingTransition() {
|
||||
clearGatewayStoppingTimeout()
|
||||
updateGatewayStore({
|
||||
status: "stopping",
|
||||
canStart: false,
|
||||
restartRequired: false,
|
||||
})
|
||||
gatewayStoppingTimer = setTimeout(() => {
|
||||
gatewayStoppingTimer = null
|
||||
updateGatewayStore((prev) =>
|
||||
prev.status === "stopping" ? { status: "running" } : prev,
|
||||
)
|
||||
void refreshGatewayState({ force: true })
|
||||
}, GATEWAY_STOPPING_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
export function cancelGatewayStoppingTransition() {
|
||||
clearGatewayStoppingTimeout()
|
||||
updateGatewayStore((prev) =>
|
||||
prev.status === "stopping" ? { status: "running" } : prev,
|
||||
)
|
||||
}
|
||||
|
||||
export function applyGatewayStatusToStore(
|
||||
@@ -64,21 +113,92 @@ export function applyGatewayStatusToStore(
|
||||
>,
|
||||
) {
|
||||
updateGatewayStore((prev) => ({
|
||||
status: data.gateway_status ?? prev.status,
|
||||
canStart: data.gateway_start_allowed ?? prev.canStart,
|
||||
restartRequired:
|
||||
data.gateway_restart_required ??
|
||||
(data.gateway_status && data.gateway_status !== "running"
|
||||
status:
|
||||
prev.status === "stopping" && data.gateway_status === "running"
|
||||
? "stopping"
|
||||
: (data.gateway_status ?? prev.status),
|
||||
canStart:
|
||||
prev.status === "stopping" && data.gateway_status === "running"
|
||||
? false
|
||||
: prev.restartRequired),
|
||||
: (data.gateway_start_allowed ?? prev.canStart),
|
||||
restartRequired:
|
||||
prev.status === "stopping" && data.gateway_status === "running"
|
||||
? false
|
||||
: (data.gateway_restart_required ?? prev.restartRequired),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function refreshGatewayState() {
|
||||
function nextGatewayPollInterval() {
|
||||
const status = getDefaultStore().get(gatewayAtom).status
|
||||
if (
|
||||
status === "starting" ||
|
||||
status === "restarting" ||
|
||||
status === "stopping"
|
||||
) {
|
||||
return GATEWAY_TRANSIENT_POLL_INTERVAL_MS
|
||||
}
|
||||
return GATEWAY_POLL_INTERVAL_MS
|
||||
}
|
||||
|
||||
function scheduleGatewayPoll(delay = nextGatewayPollInterval()) {
|
||||
if (gatewayPollingSubscribers === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (gatewayPollingTimer !== null) {
|
||||
clearTimeout(gatewayPollingTimer)
|
||||
}
|
||||
|
||||
gatewayPollingTimer = setTimeout(() => {
|
||||
gatewayPollingTimer = null
|
||||
void refreshGatewayState()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
export async function refreshGatewayState(
|
||||
options: RefreshGatewayStateOptions = {},
|
||||
) {
|
||||
if (gatewayPollingRequest) {
|
||||
await gatewayPollingRequest
|
||||
if (options.force) {
|
||||
return refreshGatewayState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
gatewayPollingRequest = (async () => {
|
||||
try {
|
||||
const status = await getGatewayStatus()
|
||||
applyGatewayStatusToStore(status)
|
||||
} catch {
|
||||
// Preserve the last known state when a poll fails.
|
||||
} finally {
|
||||
gatewayPollingRequest = null
|
||||
scheduleGatewayPoll()
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
const status = await getGatewayStatus()
|
||||
applyGatewayStatusToStore(status)
|
||||
} catch {
|
||||
updateGatewayStore(DEFAULT_GATEWAY_STATE)
|
||||
await gatewayPollingRequest
|
||||
} finally {
|
||||
if (gatewayPollingSubscribers === 0 && gatewayPollingTimer !== null) {
|
||||
clearTimeout(gatewayPollingTimer)
|
||||
gatewayPollingTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeGatewayPolling() {
|
||||
gatewayPollingSubscribers += 1
|
||||
if (gatewayPollingSubscribers === 1) {
|
||||
void refreshGatewayState()
|
||||
}
|
||||
|
||||
return () => {
|
||||
gatewayPollingSubscribers = Math.max(0, gatewayPollingSubscribers - 1)
|
||||
if (gatewayPollingSubscribers === 0 && gatewayPollingTimer !== null) {
|
||||
clearTimeout(gatewayPollingTimer)
|
||||
gatewayPollingTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user