feat: add startup readiness checks and propagate start availability to UI

- add gateway precondition validation for default model and credentials
- auto-start gateway on backend boot when conditions are met
- include gateway_start_allowed and gateway_start_reason in status updates
- prevent frontend start actions when gateway cannot be started
This commit is contained in:
wenjie 2026-03-09 10:58:58 +08:00
parent ceec9e5417
commit efed6784af
8 changed files with 338 additions and 52 deletions

View file

@ -13,6 +13,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@ -40,29 +41,70 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart) mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart)
} }
// handleGatewayStart starts the picoclaw gateway subprocess. // TryAutoStartGateway checks whether gateway start preconditions are met and
// // starts it when possible. Intended to be called by the backend at startup.
// POST /api/gateway/start func (h *Handler) TryAutoStartGateway() {
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock() gateway.mu.Lock()
defer gateway.mu.Unlock() defer gateway.mu.Unlock()
// Prevent duplicate starts if isGatewayProcessAliveLocked() {
return
}
if gateway.cmd != nil && gateway.cmd.Process != nil { if gateway.cmd != nil && gateway.cmd.Process != nil {
// Check if process is still alive (signal 0 doesn't kill, just checks)
if err := gateway.cmd.Process.Signal(syscall.Signal(0)); err == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]any{
"status": "already_running",
"pid": gateway.cmd.Process.Pid,
})
return
}
// Process is dead, clean up
gateway.cmd = nil gateway.cmd = nil
} }
ready, reason, err := h.gatewayStartReady()
if err != nil {
log.Printf("Skip auto-starting gateway: %v", err)
return
}
if !ready {
log.Printf("Skip auto-starting gateway: %s", reason)
return
}
pid, err := h.startGatewayLocked()
if err != nil {
log.Printf("Failed to auto-start gateway: %v", err)
return
}
log.Printf("Gateway auto-started (PID: %d)", pid)
}
// gatewayStartReady validates whether current config can start the gateway.
func (h *Handler) gatewayStartReady() (bool, string, error) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return false, "", fmt.Errorf("failed to load config: %w", err)
}
modelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
if modelName == "" {
return false, "no default model configured", nil
}
modelCfg, err := cfg.GetModelConfig(modelName)
if err != nil {
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
}
hasCredential := strings.TrimSpace(modelCfg.APIKey) != "" ||
strings.TrimSpace(modelCfg.AuthMethod) != ""
if !hasCredential {
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
}
return true, "", nil
}
func isGatewayProcessAliveLocked() bool {
return gateway.cmd != nil &&
gateway.cmd.Process != nil &&
gateway.cmd.Process.Signal(syscall.Signal(0)) == nil
}
func (h *Handler) startGatewayLocked() (int, error) {
// Locate the picoclaw executable // Locate the picoclaw executable
execPath := findPicoclawBinary() execPath := findPicoclawBinary()
@ -70,14 +112,12 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
stdoutPipe, err := cmd.StdoutPipe() stdoutPipe, err := cmd.StdoutPipe()
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Failed to create stdout pipe: %v", err), http.StatusInternalServerError) return 0, fmt.Errorf("failed to create stdout pipe: %w", err)
return
} }
stderrPipe, err := cmd.StderrPipe() stderrPipe, err := cmd.StderrPipe()
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Failed to create stderr pipe: %v", err), http.StatusInternalServerError) return 0, fmt.Errorf("failed to create stderr pipe: %w", err)
return
} }
// Clear old logs for this new run // Clear old logs for this new run
@ -90,8 +130,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
} }
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) return 0, fmt.Errorf("failed to start gateway: %w", err)
return
} }
gateway.cmd = cmd gateway.cmd = cmd
@ -158,10 +197,59 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
} }
}() }()
return pid, nil
}
// handleGatewayStart starts the picoclaw gateway subprocess.
//
// POST /api/gateway/start
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
// Prevent duplicate starts
if isGatewayProcessAliveLocked() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]any{
"status": "already_running",
"pid": gateway.cmd.Process.Pid,
})
return
}
if gateway.cmd != nil && gateway.cmd.Process != nil {
gateway.cmd = nil
}
ready, reason, err := h.gatewayStartReady()
if err != nil {
http.Error(
w,
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
http.StatusInternalServerError,
)
return
}
if !ready {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "precondition_failed",
"message": reason,
})
return
}
pid, err := h.startGatewayLocked()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "status": "ok",
"pid": cmd.Process.Pid, "pid": pid,
}) })
} }
@ -292,6 +380,17 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
} }
} }
ready, reason, readyErr := h.gatewayStartReady()
if readyErr != nil {
data["gateway_start_allowed"] = false
data["gateway_start_reason"] = readyErr.Error()
} else {
data["gateway_start_allowed"] = ready
if !ready {
data["gateway_start_reason"] = reason
}
}
// Append incremental log data // Append incremental log data
appendGatewayLogs(r, data) appendGatewayLogs(r, data)
@ -385,16 +484,29 @@ func (h *Handler) currentGatewayStatus() string {
gateway.mu.Lock() gateway.mu.Lock()
defer gateway.mu.Unlock() defer gateway.mu.Unlock()
event := GatewayEvent{Status: "stopped"} data := map[string]any{
"gateway_status": "stopped",
}
if gateway.cmd != nil && gateway.cmd.Process != nil { if gateway.cmd != nil && gateway.cmd.Process != nil {
if err := gateway.cmd.Process.Signal(syscall.Signal(0)); err == nil { if err := gateway.cmd.Process.Signal(syscall.Signal(0)); err == nil {
event.Status = "running" data["gateway_status"] = "running"
event.PID = gateway.cmd.Process.Pid data["pid"] = gateway.cmd.Process.Pid
} }
} }
data, _ := json.Marshal(event) ready, reason, readyErr := h.gatewayStartReady()
return string(data) if readyErr != nil {
data["gateway_start_allowed"] = false
data["gateway_start_reason"] = readyErr.Error()
} else {
data["gateway_start_allowed"] = ready
if !ready {
data["gateway_start_reason"] = reason
}
}
encoded, _ := json.Marshal(data)
return string(encoded)
} }
// findPicoclawBinary locates the picoclaw executable. // findPicoclawBinary locates the picoclaw executable.

View file

@ -0,0 +1,122 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false")
}
if reason != "no default model configured" {
t.Fatalf("gatewayStartReady() reason = %q, want %q", reason, "no default model configured")
}
}
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Model = "missing-model"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false")
}
if reason == "" {
t.Fatalf("gatewayStartReady() reason is empty")
}
}
func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = "test-key"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if !ready {
t.Fatalf("gatewayStartReady() ready = false, want true (reason=%q)", reason)
}
}
func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].APIKey = ""
cfg.ModelList[0].AuthMethod = ""
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
ready, reason, err := h.gatewayStartReady()
if err != nil {
t.Fatalf("gatewayStartReady() error = %v", err)
}
if ready {
t.Fatalf("gatewayStartReady() ready = true, want false")
}
if !strings.Contains(reason, "no credentials configured") {
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured")
}
}
func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
allowed, ok := body["gateway_start_allowed"].(bool)
if !ok {
t.Fatalf("gateway_start_allowed missing or not bool: %#v", body["gateway_start_allowed"])
}
if allowed {
t.Fatalf("gateway_start_allowed = true, want false")
}
if _, ok := body["gateway_start_reason"].(string); !ok {
t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"])
}
}

View file

@ -107,6 +107,12 @@ func main() {
}() }()
} }
// Auto-start gateway after backend starts listening.
go func() {
time.Sleep(1 * time.Second)
apiHandler.TryAutoStartGateway()
}()
// Start the Server // Start the Server
if err := http.ListenAndServe(addr, handler); err != nil { if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("Server failed to start: %v", err) log.Fatalf("Server failed to start: %v", err)

View file

@ -2,6 +2,8 @@
interface GatewayStatusResponse { interface GatewayStatusResponse {
gateway_status: "running" | "starting" | "stopped" | "error" gateway_status: "running" | "starting" | "stopped" | "error"
gateway_start_allowed?: boolean
gateway_start_reason?: string
pid?: number pid?: number
logs?: string[] logs?: string[]
log_total?: number log_total?: number

View file

@ -40,7 +40,7 @@ export function AppHeader() {
const { const {
state: gwState, state: gwState,
loading: gwLoading, loading: gwLoading,
isInitialized, canStart,
start, start,
stop, stop,
} = useGateway() } = useGateway()
@ -48,11 +48,13 @@ export function AppHeader() {
const isRunning = gwState === "running" const isRunning = gwState === "running"
const isStarting = gwState === "starting" const isStarting = gwState === "starting"
const isStopped = gwState === "stopped" || gwState === "unknown" const isStopped = gwState === "stopped" || gwState === "unknown"
const showNotConnectedHint =
canStart && (gwState === "stopped" || gwState === "error")
const [showStopDialog, setShowStopDialog] = React.useState(false) const [showStopDialog, setShowStopDialog] = React.useState(false)
const handleGatewayToggle = () => { const handleGatewayToggle = () => {
if (gwLoading) return if (gwLoading || (!isRunning && !canStart)) return
if (isRunning) { if (isRunning) {
setShowStopDialog(true) setShowStopDialog(true)
} else { } else {
@ -80,7 +82,7 @@ export function AppHeader() {
{/* Center prominent connection status */} {/* Center prominent connection status */}
<div className="pointer-events-none absolute left-1/2 hidden h-full -translate-x-1/2 items-center justify-center lg:flex"> <div className="pointer-events-none absolute left-1/2 hidden h-full -translate-x-1/2 items-center justify-center lg:flex">
{isInitialized && !isRunning && !isStarting && ( {showNotConnectedHint && (
<div className="text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-4 py-1.5 text-xs shadow-sm backdrop-blur-md"> <div className="text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-4 py-1.5 text-xs shadow-sm backdrop-blur-md">
<span className="bg-destructive/50 relative flex size-2 shrink-0 items-center justify-center rounded-full"> <span className="bg-destructive/50 relative flex size-2 shrink-0 items-center justify-center rounded-full">
<span className="bg-destructive absolute inline-flex size-full animate-ping rounded-full opacity-75"></span> <span className="bg-destructive absolute inline-flex size-full animate-ping rounded-full opacity-75"></span>
@ -133,7 +135,7 @@ export function AppHeader() {
: "" : ""
}`} }`}
onClick={handleGatewayToggle} onClick={handleGatewayToggle}
disabled={gwLoading || isStarting} disabled={gwLoading || isStarting || (!isRunning && !canStart)}
> >
{gwLoading || isStarting ? ( {gwLoading || isStarting ? (
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" /> <IconLoader2 className="h-4 w-4 animate-spin opacity-70" />

View file

@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize" import TextareaAutosize from "react-textarea-autosize"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface ChatComposerProps { interface ChatComposerProps {
input: string input: string
@ -21,6 +22,7 @@ export function ChatComposer({
hasDefaultModel, hasDefaultModel,
}: ChatComposerProps) { }: ChatComposerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const canInput = isConnected && hasDefaultModel
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return if (e.nativeEvent.isComposing) return
@ -32,14 +34,17 @@ export function ChatComposer({
return ( return (
<div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48"> <div className="bg-background shrink-0 px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md"> <div className="bg-card border-border/80 mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-md">
<TextareaAutosize <TextareaAutosize
value={input} value={input}
onChange={(e) => onInputChange(e.target.value)} onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={t("chat.placeholder")} placeholder={t("chat.placeholder")}
disabled={!isConnected || !hasDefaultModel} disabled={!canInput}
className="max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent" className={cn(
"max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
!canInput && "cursor-not-allowed",
)}
minRows={1} minRows={1}
maxRows={8} maxRows={8}
/> />

View file

@ -1,43 +1,72 @@
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { getGatewayStatus, startGateway, stopGateway } from "@/api/gateway" import {
type GatewayStatusResponse,
getGatewayStatus,
startGateway,
stopGateway,
} from "@/api/gateway"
import { gatewayAtom } from "@/store" import { gatewayAtom } from "@/store"
// Global variable to ensure we only have one SSE connection // Global variable to ensure we only have one SSE connection
let sseInitialized = false let sseInitialized = false
export function useGateway() { export function useGateway() {
const [{ status: state, isInitialized }, setGateway] = useAtom(gatewayAtom) const [{ status: state, canStart }, setGateway] = useAtom(gatewayAtom)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const applyGatewayStatus = useCallback(
(data: GatewayStatusResponse) => {
setGateway((prev) => ({
...prev,
status: data.gateway_status ?? "unknown",
canStart: data.gateway_start_allowed ?? true,
}))
},
[setGateway],
)
// Initialize global SSE connection once // Initialize global SSE connection once
useEffect(() => { useEffect(() => {
if (sseInitialized) return if (sseInitialized) return
sseInitialized = true sseInitialized = true
getGatewayStatus() getGatewayStatus()
.then((data) => { .then((data) => applyGatewayStatus(data))
setGateway({
status: data.gateway_status ?? "unknown",
isInitialized: true,
})
})
.catch(() => { .catch(() => {
setGateway({ setGateway({
status: "unknown", status: "unknown",
isInitialized: true, canStart: true,
}) })
}) })
const statusPoll = window.setInterval(() => {
getGatewayStatus()
.then((data) => applyGatewayStatus(data))
.catch(() => {
// ignore polling errors
})
}, 5000)
// Subscribe to SSE for real-time updates globally // Subscribe to SSE for real-time updates globally
const es = new EventSource("/api/gateway/events") const es = new EventSource("/api/gateway/events")
es.onmessage = (event) => { es.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.gateway_status) { if (
setGateway((prev) => ({ ...prev, status: data.gateway_status })) data.gateway_status ||
typeof data.gateway_start_allowed === "boolean"
) {
setGateway((prev) => ({
...prev,
status: data.gateway_status ?? prev.status,
canStart:
typeof data.gateway_start_allowed === "boolean"
? data.gateway_start_allowed
: prev.canStart,
}))
} }
} catch { } catch {
// ignore // ignore
@ -50,12 +79,15 @@ export function useGateway() {
} }
return () => { return () => {
window.clearInterval(statusPoll)
es.close() es.close()
sseInitialized = false sseInitialized = false
} }
}, [setGateway]) }, [applyGatewayStatus, setGateway])
const start = useCallback(async () => { const start = useCallback(async () => {
if (!canStart) return
setLoading(true) setLoading(true)
try { try {
await startGateway() await startGateway()
@ -63,11 +95,16 @@ export function useGateway() {
setGateway((prev) => ({ ...prev, status: "starting" })) setGateway((prev) => ({ ...prev, status: "starting" }))
} catch (err) { } catch (err) {
console.error("Failed to start gateway:", err) console.error("Failed to start gateway:", err)
setGateway((prev) => ({ ...prev, status: "unknown" })) try {
const status = await getGatewayStatus()
applyGatewayStatus(status)
} catch {
setGateway((prev) => ({ ...prev, status: "unknown" }))
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [setGateway]) }, [applyGatewayStatus, canStart, setGateway])
const stop = useCallback(async () => { const stop = useCallback(async () => {
setLoading(true) setLoading(true)
@ -80,5 +117,5 @@ export function useGateway() {
} }
}, []) }, [])
return { state, loading, isInitialized, start, stop } return { state, loading, canStart, start, stop }
} }

View file

@ -8,12 +8,12 @@ export type GatewayState =
| "unknown" | "unknown"
export interface GatewayStoreState { export interface GatewayStoreState {
isInitialized: boolean
status: GatewayState status: GatewayState
canStart: boolean
} }
// Global atom for gateway state // Global atom for gateway state
export const gatewayAtom = atom<GatewayStoreState>({ export const gatewayAtom = atom<GatewayStoreState>({
isInitialized: false,
status: "unknown", status: "unknown",
canStart: true,
}) })