* feat(runtime): support CLI update from web runtime page
Add the ability to update the CLI daemon from the web Runtime detail page.
When a newer version is available on GitHub Releases, an update button
appears. Clicking it sends an update command through the server to the
daemon via the heartbeat mechanism (same pattern as ping). The daemon
executes `brew upgrade`, reports the result, and restarts itself with the
new binary.
Changes across all three layers:
- Frontend: version display, GitHub latest check, UpdateSection component
- Server: UpdateStore (in-memory), heartbeat extension, 3 new endpoints
- CLI: shared update logic, daemon handleUpdate + graceful restart
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runtime): handle 'running' status in ReportUpdateResult
The daemon sends {"status":"running"} when it starts executing the
update, but ReportUpdateResult treated any non-"completed" status as
failure — immediately marking the update as failed before brew upgrade
even ran.
Fix: use a switch statement to handle "running" as a no-op (status is
already "running" from PopPending), and also timeout running updates
after 120 seconds in case brew upgrade hangs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
221 lines
5.9 KiB
Go
221 lines
5.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-memory update store
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type UpdateStatus string
|
|
|
|
const (
|
|
UpdatePending UpdateStatus = "pending"
|
|
UpdateRunning UpdateStatus = "running"
|
|
UpdateCompleted UpdateStatus = "completed"
|
|
UpdateFailed UpdateStatus = "failed"
|
|
UpdateTimeout UpdateStatus = "timeout"
|
|
)
|
|
|
|
// UpdateRequest represents a pending or completed CLI update request.
|
|
type UpdateRequest struct {
|
|
ID string `json:"id"`
|
|
RuntimeID string `json:"runtime_id"`
|
|
Status UpdateStatus `json:"status"`
|
|
TargetVersion string `json:"target_version"`
|
|
Output string `json:"output,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// UpdateStore is a thread-safe in-memory store for CLI update requests.
|
|
type UpdateStore struct {
|
|
mu sync.Mutex
|
|
requests map[string]*UpdateRequest // keyed by update ID
|
|
}
|
|
|
|
func NewUpdateStore() *UpdateStore {
|
|
return &UpdateStore{
|
|
requests: make(map[string]*UpdateRequest),
|
|
}
|
|
}
|
|
|
|
func (s *UpdateStore) Create(runtimeID, targetVersion string) (*UpdateRequest, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Clean up old requests (>5 minutes).
|
|
for id, req := range s.requests {
|
|
if time.Since(req.CreatedAt) > 5*time.Minute {
|
|
delete(s.requests, id)
|
|
}
|
|
}
|
|
|
|
// Reject if there is already a pending or running update for this runtime.
|
|
for _, req := range s.requests {
|
|
if req.RuntimeID == runtimeID && (req.Status == UpdatePending || req.Status == UpdateRunning) {
|
|
return nil, errUpdateInProgress
|
|
}
|
|
}
|
|
|
|
req := &UpdateRequest{
|
|
ID: randomID(),
|
|
RuntimeID: runtimeID,
|
|
Status: UpdatePending,
|
|
TargetVersion: targetVersion,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
s.requests[req.ID] = req
|
|
return req, nil
|
|
}
|
|
|
|
var errUpdateInProgress = &updateError{msg: "an update is already in progress for this runtime"}
|
|
|
|
type updateError struct{ msg string }
|
|
|
|
func (e *updateError) Error() string { return e.msg }
|
|
|
|
func (s *UpdateStore) Get(id string) *UpdateRequest {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
req, ok := s.requests[id]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
// Check for timeout (both pending and running states).
|
|
if (req.Status == UpdatePending || req.Status == UpdateRunning) && time.Since(req.CreatedAt) > 120*time.Second {
|
|
req.Status = UpdateTimeout
|
|
req.Error = "update did not complete within 120 seconds"
|
|
req.UpdatedAt = time.Now()
|
|
}
|
|
return req
|
|
}
|
|
|
|
// PopPending returns and marks as running the pending update for a runtime.
|
|
func (s *UpdateStore) PopPending(runtimeID string) *UpdateRequest {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for _, req := range s.requests {
|
|
if req.RuntimeID == runtimeID && req.Status == UpdatePending {
|
|
req.Status = UpdateRunning
|
|
req.UpdatedAt = time.Now()
|
|
return req
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *UpdateStore) Complete(id string, output string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if req, ok := s.requests[id]; ok {
|
|
req.Status = UpdateCompleted
|
|
req.Output = output
|
|
req.UpdatedAt = time.Now()
|
|
}
|
|
}
|
|
|
|
func (s *UpdateStore) Fail(id string, errMsg string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if req, ok := s.requests[id]; ok {
|
|
req.Status = UpdateFailed
|
|
req.Error = errMsg
|
|
req.UpdatedAt = time.Now()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// InitiateUpdate creates a new CLI update request (protected route, called by frontend).
|
|
func (h *Handler) InitiateUpdate(w http.ResponseWriter, r *http.Request) {
|
|
runtimeID := chi.URLParam(r, "runtimeId")
|
|
|
|
rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "runtime not found")
|
|
return
|
|
}
|
|
|
|
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(rt.WorkspaceID), "runtime not found"); !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
TargetVersion string `json:"target_version"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.TargetVersion == "" {
|
|
writeError(w, http.StatusBadRequest, "target_version is required")
|
|
return
|
|
}
|
|
|
|
update, err := h.UpdateStore.Create(runtimeID, req.TargetVersion)
|
|
if err != nil {
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, update)
|
|
}
|
|
|
|
// GetUpdate returns the status of an update request (protected route, called by frontend).
|
|
func (h *Handler) GetUpdate(w http.ResponseWriter, r *http.Request) {
|
|
updateID := chi.URLParam(r, "updateId")
|
|
|
|
update := h.UpdateStore.Get(updateID)
|
|
if update == nil {
|
|
writeError(w, http.StatusNotFound, "update not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, update)
|
|
}
|
|
|
|
// ReportUpdateResult receives the update result from the daemon.
|
|
func (h *Handler) ReportUpdateResult(w http.ResponseWriter, r *http.Request) {
|
|
updateID := chi.URLParam(r, "updateId")
|
|
|
|
var req struct {
|
|
Status string `json:"status"` // "running", "completed", or "failed"
|
|
Output string `json:"output"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
switch req.Status {
|
|
case "completed":
|
|
h.UpdateStore.Complete(updateID, req.Output)
|
|
case "failed":
|
|
h.UpdateStore.Fail(updateID, req.Error)
|
|
case "running":
|
|
// No-op: status is already "running" from PopPending. This call is
|
|
// just a progress signal from the daemon to confirm it received the
|
|
// update command and is executing it.
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid status: "+req.Status)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|