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"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
@ -40,29 +41,70 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart)
}
// handleGatewayStart starts the picoclaw gateway subprocess.
//
// POST /api/gateway/start
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
// TryAutoStartGateway checks whether gateway start preconditions are met and
// starts it when possible. Intended to be called by the backend at startup.
func (h *Handler) TryAutoStartGateway() {
gateway.mu.Lock()
defer gateway.mu.Unlock()
// Prevent duplicate starts
if isGatewayProcessAliveLocked() {
return
}
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
}
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
execPath := findPicoclawBinary()
@ -70,14 +112,12 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create stdout pipe: %v", err), http.StatusInternalServerError)
return
return 0, fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create stderr pipe: %v", err), http.StatusInternalServerError)
return
return 0, fmt.Errorf("failed to create stderr pipe: %w", err)
}
// 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 {
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
return 0, fmt.Errorf("failed to start gateway: %w", err)
}
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")
json.NewEncoder(w).Encode(map[string]any{
"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
appendGatewayLogs(r, data)
@ -385,16 +484,29 @@ func (h *Handler) currentGatewayStatus() string {
gateway.mu.Lock()
defer gateway.mu.Unlock()
event := GatewayEvent{Status: "stopped"}
data := map[string]any{
"gateway_status": "stopped",
}
if gateway.cmd != nil && gateway.cmd.Process != nil {
if err := gateway.cmd.Process.Signal(syscall.Signal(0)); err == nil {
event.Status = "running"
event.PID = gateway.cmd.Process.Pid
data["gateway_status"] = "running"
data["pid"] = gateway.cmd.Process.Pid
}
}
data, _ := json.Marshal(event)
return string(data)
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
}
}
encoded, _ := json.Marshal(data)
return string(encoded)
}
// 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
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("Server failed to start: %v", err)

View file

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

View file

@ -40,7 +40,7 @@ export function AppHeader() {
const {
state: gwState,
loading: gwLoading,
isInitialized,
canStart,
start,
stop,
} = useGateway()
@ -48,11 +48,13 @@ export function AppHeader() {
const isRunning = gwState === "running"
const isStarting = gwState === "starting"
const isStopped = gwState === "stopped" || gwState === "unknown"
const showNotConnectedHint =
canStart && (gwState === "stopped" || gwState === "error")
const [showStopDialog, setShowStopDialog] = React.useState(false)
const handleGatewayToggle = () => {
if (gwLoading) return
if (gwLoading || (!isRunning && !canStart)) return
if (isRunning) {
setShowStopDialog(true)
} else {
@ -80,7 +82,7 @@ export function AppHeader() {
{/* 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">
{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">
<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>
@ -133,7 +135,7 @@ export function AppHeader() {
: ""
}`}
onClick={handleGatewayToggle}
disabled={gwLoading || isStarting}
disabled={gwLoading || isStarting || (!isRunning && !canStart)}
>
{gwLoading || isStarting ? (
<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 { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface ChatComposerProps {
input: string
@ -21,6 +22,7 @@ export function ChatComposer({
hasDefaultModel,
}: ChatComposerProps) {
const { t } = useTranslation()
const canInput = isConnected && hasDefaultModel
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return
@ -32,14 +34,17 @@ export function ChatComposer({
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-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
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("chat.placeholder")}
disabled={!isConnected || !hasDefaultModel}
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"
disabled={!canInput}
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}
maxRows={8}
/>

View file

@ -1,43 +1,72 @@
import { useAtom } from "jotai"
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"
// Global variable to ensure we only have one SSE connection
let sseInitialized = false
export function useGateway() {
const [{ status: state, isInitialized }, setGateway] = useAtom(gatewayAtom)
const [{ status: state, canStart }, setGateway] = useAtom(gatewayAtom)
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
useEffect(() => {
if (sseInitialized) return
sseInitialized = true
getGatewayStatus()
.then((data) => {
setGateway({
status: data.gateway_status ?? "unknown",
isInitialized: true,
})
})
.then((data) => applyGatewayStatus(data))
.catch(() => {
setGateway({
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
const es = new EventSource("/api/gateway/events")
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.gateway_status) {
setGateway((prev) => ({ ...prev, status: data.gateway_status }))
if (
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 {
// ignore
@ -50,12 +79,15 @@ export function useGateway() {
}
return () => {
window.clearInterval(statusPoll)
es.close()
sseInitialized = false
}
}, [setGateway])
}, [applyGatewayStatus, setGateway])
const start = useCallback(async () => {
if (!canStart) return
setLoading(true)
try {
await startGateway()
@ -63,11 +95,16 @@ export function useGateway() {
setGateway((prev) => ({ ...prev, status: "starting" }))
} catch (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 {
setLoading(false)
}
}, [setGateway])
}, [applyGatewayStatus, canStart, setGateway])
const stop = useCallback(async () => {
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"
export interface GatewayStoreState {
isInitialized: boolean
status: GatewayState
canStart: boolean
}
// Global atom for gateway state
export const gatewayAtom = atom<GatewayStoreState>({
isInitialized: false,
status: "unknown",
canStart: true,
})