mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
fix(picoclaw): cancel runtime lifecycle on mode switch
Cancel PicoClaw start/readiness waits when MCP takes control, clear held manual input state after failed release reports, and keep paste duration below the control-mode wait budget. Also add MCP copy failure translations and avoid reconnect effect churn when the locale changes.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package hid
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReportLengthValidation(t *testing.T) {
|
||||
h := &Hid{}
|
||||
@@ -14,3 +17,12 @@ func TestReportLengthValidation(t *testing.T) {
|
||||
t.Fatal("expected absolute mouse length error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteDurationLeavesModeSwitchMargin(t *testing.T) {
|
||||
if maxPasteDuration >= 30*time.Second {
|
||||
t.Fatalf("maxPasteDuration = %s, want below 30s mode switch wait budget", maxPasteDuration)
|
||||
}
|
||||
if got := time.Duration(maxPasteContentRunes) * defaultPasteDelay; got > maxPasteDuration {
|
||||
t.Fatalf("max paste content duration = %s, want <= %s", got, maxPasteDuration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type PasteReq struct {
|
||||
|
||||
const (
|
||||
defaultPasteDelay = 30 * time.Millisecond
|
||||
maxPasteDuration = 30 * time.Second
|
||||
maxPasteDuration = 25 * time.Second
|
||||
maxPasteContentRunes = int(maxPasteDuration / defaultPasteDelay)
|
||||
)
|
||||
|
||||
@@ -193,7 +193,7 @@ func (s *Service) Paste(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if time.Duration(typeableRunes)*defaultPasteDelay > maxPasteDuration {
|
||||
rsp.ErrRsp(c, -2, "paste duration exceeds 30s")
|
||||
rsp.ErrRsp(c, -2, "paste duration exceeds 25s")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -429,6 +429,10 @@ func (s *ManualSession) complete(generation uint64, kind ManualReportKind, held
|
||||
case ManualAbsoluteMouse:
|
||||
s.absoluteMouseHeld = held
|
||||
}
|
||||
} else {
|
||||
s.keyboardHeld = false
|
||||
s.relativeMouseHeld = false
|
||||
s.absoluteMouseHeld = false
|
||||
}
|
||||
releaseControl, end, startCooldown := s.finishIfIdleLocked()
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -162,6 +162,49 @@ func TestFailedHeldManualInputDoesNotRemainHeld(t *testing.T) {
|
||||
release()
|
||||
}
|
||||
|
||||
func TestFailedReleaseReportClearsHeldManualInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind ManualReportKind
|
||||
}{
|
||||
{name: "keyboard", kind: ManualKeyboard},
|
||||
{name: "relative mouse", kind: ManualRelativeMouse},
|
||||
{name: "absolute mouse", kind: ManualAbsoluteMouse},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
now := time.Unix(260, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), tc.kind, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
up, err := manual.Reserve(context.Background(), tc.kind, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up.Complete(false)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed release report left held input active: %v", err)
|
||||
}
|
||||
release()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointerMoveWithoutButtonsDoesNotStartCooldown(t *testing.T) {
|
||||
coordinator := newCoordinator(time.Second, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
@@ -327,6 +370,40 @@ func TestBlockedActiveSessionStillAllowsReleaseReport(t *testing.T) {
|
||||
up.Complete(true)
|
||||
}
|
||||
|
||||
func TestBlockedActiveSessionFailedReleaseClearsHeldReport(t *testing.T) {
|
||||
now := time.Unix(325, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
if _, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return false }); !errors.Is(err, ErrManualInputBlocked) {
|
||||
t.Fatalf("new held report error = %v, want %v", err, ErrManualInputBlocked)
|
||||
}
|
||||
|
||||
up, err := manual.Reserve(context.Background(), ManualKeyboard, false, func(controlmode.Mode) bool { return false })
|
||||
if err != nil {
|
||||
t.Fatalf("release report was blocked: %v", err)
|
||||
}
|
||||
up.Complete(false)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed blocked release report left held input active: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestReadOnlyMCPAllowedDuringManualControl(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
|
||||
@@ -104,6 +104,17 @@ func (s *Service) beginControlOperation(parent context.Context) (context.Context
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) beginRuntimeLifecycleOperation(parent context.Context) (context.Context, func()) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
if s == nil || s.operations == nil {
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
return ctx, func() { cancel(context.Canceled) }
|
||||
}
|
||||
return s.operations.begin(parent)
|
||||
}
|
||||
|
||||
func (s *Service) CancelActiveControlOperations() int {
|
||||
if s == nil || s.operations == nil {
|
||||
return 0
|
||||
@@ -115,6 +126,22 @@ func (s *Service) CancelActiveControlOperations() int {
|
||||
return count
|
||||
}
|
||||
|
||||
func runtimeLifecycleOperationError(ctx context.Context) *PicoclawError {
|
||||
if ctx == nil || ctx.Err() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cause := context.Cause(ctx)
|
||||
switch {
|
||||
case errors.Is(cause, errControlModeSwitch):
|
||||
return newPicoclawError(CodeControlModeConflict, "PicoClaw runtime lifecycle canceled because the control mode is switching")
|
||||
case errors.Is(cause, context.DeadlineExceeded):
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle timed out")
|
||||
default:
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle was canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func controlOperationError(ctx context.Context) *PicoclawError {
|
||||
if ctx == nil || ctx.Err() == nil {
|
||||
return nil
|
||||
|
||||
@@ -67,6 +67,70 @@ func TestControlModeSwitchCancelsActiveWait(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlModeSwitchCancelsRuntimeLifecycle(t *testing.T) {
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
service := &Service{
|
||||
control: control,
|
||||
operations: newControlOperationTracker(),
|
||||
}
|
||||
|
||||
operationCtx, releaseOperation := service.beginRuntimeLifecycleOperation(context.Background())
|
||||
releaseMode, modeErr := service.acquireControlMode()
|
||||
if modeErr != nil {
|
||||
t.Fatal(modeErr)
|
||||
}
|
||||
|
||||
lifecycleDone := make(chan *PicoclawError, 1)
|
||||
go func() {
|
||||
<-operationCtx.Done()
|
||||
lifecycleErr := runtimeLifecycleOperationError(operationCtx)
|
||||
releaseOperation()
|
||||
releaseMode()
|
||||
lifecycleDone <- lifecycleErr
|
||||
}()
|
||||
|
||||
switchDone := make(chan error, 1)
|
||||
go func() {
|
||||
switchDone <- control.Switch(controlmode.ModeMCP, func() error {
|
||||
service.CancelActiveControlOperations()
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-switchDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("control mode switch did not cancel the runtime lifecycle operation")
|
||||
}
|
||||
|
||||
select {
|
||||
case lifecycleErr := <-lifecycleDone:
|
||||
if lifecycleErr == nil || lifecycleErr.Code != CodeControlModeConflict {
|
||||
t.Fatalf("lifecycle error = %+v, want %s", lifecycleErr, CodeControlModeConflict)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("canceled runtime lifecycle operation did not return")
|
||||
}
|
||||
|
||||
if got := control.Current(); got != controlmode.ModeMCP {
|
||||
t.Fatalf("mode = %q, want %q", got, controlmode.ModeMCP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeReadyWaitHonorsLifecycleCancellation(t *testing.T) {
|
||||
service := &Service{}
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(errControlModeSwitch)
|
||||
|
||||
err := service.waitForRuntimeReadyContext(ctx, time.Hour)
|
||||
if err == nil || err.Code != CodeControlModeConflict {
|
||||
t.Fatalf("error = %+v, want %s", err, CodeControlModeConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitDurationIsBounded(t *testing.T) {
|
||||
service := &Service{}
|
||||
_, err := service.executeAction(context.Background(), Action{
|
||||
|
||||
@@ -35,16 +35,24 @@ func (s *Service) StartRuntime(c *gin.Context) {
|
||||
}
|
||||
defer releaseControl()
|
||||
|
||||
operationCtx, releaseOperation := s.beginRuntimeLifecycleOperation(c.Request.Context())
|
||||
defer releaseOperation()
|
||||
|
||||
unlockLifecycle := s.lockRuntimeLifecycle()
|
||||
defer unlockLifecycle()
|
||||
|
||||
if lifecycleErr := runtimeLifecycleOperationError(operationCtx); lifecycleErr != nil {
|
||||
writePicoclawErrorWithData(c, lifecycleErr, gin.H{"status": s.runtimeStatus()})
|
||||
return
|
||||
}
|
||||
|
||||
if currentStatus := s.runtime.Get(); currentStatus.Installing {
|
||||
runtimeErr := newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress")
|
||||
writePicoclawErrorWithData(c, runtimeErr, gin.H{"status": s.runtimeStatus()})
|
||||
return
|
||||
}
|
||||
|
||||
if readyErr := s.ensureRuntimeReadyForLifecycle(); readyErr == nil {
|
||||
if readyErr := s.ensureRuntimeReadyForLifecycleContext(operationCtx); readyErr == nil {
|
||||
s.setRuntimeIntentDesired(true, "web")
|
||||
status := s.runtimeStatus()
|
||||
log.WithFields(log.Fields{
|
||||
@@ -67,8 +75,9 @@ func (s *Service) StartRuntime(c *gin.Context) {
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
|
||||
command, output, startErr := s.startRuntime()
|
||||
command, output, startErr := s.startRuntimeContext(operationCtx)
|
||||
if startErr != nil {
|
||||
s.markRuntimeLifecycleCanceled(startErr)
|
||||
s.setRuntimeIntentError(startErr.Message)
|
||||
status := s.runtimeStatus()
|
||||
log.WithFields(log.Fields{
|
||||
|
||||
@@ -14,9 +14,19 @@ import (
|
||||
)
|
||||
|
||||
func (s *Service) startRuntime() (string, string, *PicoclawError) {
|
||||
return s.startRuntimeContext(context.Background())
|
||||
}
|
||||
|
||||
func (s *Service) startRuntimeContext(ctx context.Context) (string, string, *PicoclawError) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if s == nil {
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
|
||||
}
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return "", "", lifecycleErr
|
||||
}
|
||||
s.ensureDependencies()
|
||||
|
||||
if installed, statErr := isPicoclawInstalled(); statErr != nil {
|
||||
@@ -77,12 +87,15 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
|
||||
}
|
||||
|
||||
command := scriptPath + " start"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout)
|
||||
startCtx, cancel := context.WithTimeout(ctx, picoclawStartTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
|
||||
output, execErr := exec.CommandContext(startCtx, "sh", "-c", command).CombinedOutput()
|
||||
trimmedOutput := strings.TrimSpace(string(output))
|
||||
if execErr != nil {
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return command, trimmedOutput, lifecycleErr
|
||||
}
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Installed = true
|
||||
@@ -96,17 +109,24 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
|
||||
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime")
|
||||
}
|
||||
|
||||
time.Sleep(picoclawStartWaitPeriod)
|
||||
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil {
|
||||
if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawStartWaitPeriod); lifecycleErr != nil {
|
||||
return command, trimmedOutput, lifecycleErr
|
||||
}
|
||||
if runtimeErr := s.waitForRuntimeReadyContext(ctx, picoclawStartTimeout); runtimeErr != nil {
|
||||
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message)
|
||||
failureStatus := "unavailable"
|
||||
if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil {
|
||||
failureStatus = "error"
|
||||
startErr.Message = fmt.Sprintf(
|
||||
"%s; failed to stop partially started runtime: %v",
|
||||
startErr.Message,
|
||||
cleanupErr,
|
||||
)
|
||||
if runtimeErr.Code == CodeControlModeConflict {
|
||||
startErr = runtimeErr
|
||||
failureStatus = "starting"
|
||||
} else {
|
||||
if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil {
|
||||
failureStatus = "error"
|
||||
startErr.Message = fmt.Sprintf(
|
||||
"%s; failed to stop partially started runtime: %v",
|
||||
startErr.Message,
|
||||
cleanupErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
@@ -121,6 +141,23 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
|
||||
return command, trimmedOutput, nil
|
||||
}
|
||||
|
||||
func (s *Service) markRuntimeLifecycleCanceled(err *PicoclawError) {
|
||||
if s == nil || err == nil || err.Code != CodeControlModeConflict {
|
||||
return
|
||||
}
|
||||
s.ensureDependencies()
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
if isRuntimeLifecycleStatusPending(*status) {
|
||||
status.Status = "stopped"
|
||||
}
|
||||
status.LastError = err.Message
|
||||
status.CurrentSession = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) stopRuntime() (string, string, *PicoclawError) {
|
||||
if s == nil {
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
|
||||
@@ -183,11 +220,22 @@ func (s *Service) stopRuntime() (string, string, *PicoclawError) {
|
||||
}
|
||||
|
||||
func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
|
||||
return s.waitForRuntimeReadyContext(context.Background(), timeout)
|
||||
}
|
||||
|
||||
func (s *Service) waitForRuntimeReadyContext(ctx context.Context, timeout time.Duration) *PicoclawError {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr *PicoclawError
|
||||
|
||||
for {
|
||||
runtimeErr := s.ensureRuntimeReadyForLifecycle()
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return lifecycleErr
|
||||
}
|
||||
|
||||
runtimeErr := s.ensureRuntimeReadyForLifecycleContext(ctx)
|
||||
if runtimeErr == nil {
|
||||
return nil
|
||||
} else {
|
||||
@@ -198,7 +246,9 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(picoclawReadyPollPeriod)
|
||||
if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawReadyPollPeriod); lifecycleErr != nil {
|
||||
return lifecycleErr
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
@@ -218,18 +268,31 @@ func resolvePicoclawStartScript() (string, error) {
|
||||
}
|
||||
|
||||
func runPicoclawOnboard() (string, *PicoclawError) {
|
||||
return runPicoclawOnboardContext(context.Background())
|
||||
}
|
||||
|
||||
func runPicoclawOnboardContext(ctx context.Context) (string, *PicoclawError) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
scriptPath, err := resolvePicoclawStartScript()
|
||||
if err != nil {
|
||||
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
|
||||
}
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return "", lifecycleErr
|
||||
}
|
||||
|
||||
command := scriptPath + " onboard"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout)
|
||||
onboardCtx, cancel := context.WithTimeout(ctx, picoclawOnboardTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
|
||||
output, execErr := exec.CommandContext(onboardCtx, "sh", "-c", command).CombinedOutput()
|
||||
trimmedOutput := strings.TrimSpace(string(output))
|
||||
if execErr != nil {
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return trimmedOutput, lifecycleErr
|
||||
}
|
||||
if trimmedOutput == "" {
|
||||
trimmedOutput = execErr.Error()
|
||||
}
|
||||
@@ -239,6 +302,23 @@ func runPicoclawOnboard() (string, *PicoclawError) {
|
||||
return trimmedOutput, nil
|
||||
}
|
||||
|
||||
func sleepRuntimeLifecycleContext(ctx context.Context, delay time.Duration) *PicoclawError {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if delay <= 0 {
|
||||
return runtimeLifecycleOperationError(ctx)
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return runtimeLifecycleOperationError(ctx)
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isPicoclawInstalled() (bool, error) {
|
||||
info, err := os.Stat(picoclawBinaryPath)
|
||||
if err == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -355,17 +356,24 @@ func (s *Service) lockRuntimeLifecycle() func() {
|
||||
}
|
||||
|
||||
func (s *Service) ensureRuntimeReady() *PicoclawError {
|
||||
return s.ensureRuntimeReadyWithProbeProtection(false)
|
||||
return s.ensureRuntimeReadyWithProbeProtection(context.Background(), false)
|
||||
}
|
||||
|
||||
func (s *Service) ensureRuntimeReadyForLifecycle() *PicoclawError {
|
||||
return s.ensureRuntimeReadyWithProbeProtection(true)
|
||||
return s.ensureRuntimeReadyForLifecycleContext(context.Background())
|
||||
}
|
||||
|
||||
func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite bool) *PicoclawError {
|
||||
func (s *Service) ensureRuntimeReadyForLifecycleContext(ctx context.Context) *PicoclawError {
|
||||
return s.ensureRuntimeReadyWithProbeProtection(ctx, true)
|
||||
}
|
||||
|
||||
func (s *Service) ensureRuntimeReadyWithProbeProtection(ctx context.Context, allowLifecycleOverwrite bool) *PicoclawError {
|
||||
if s == nil {
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw service is unavailable")
|
||||
}
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return lifecycleErr
|
||||
}
|
||||
s.ensureDependencies()
|
||||
setStatus := func(status RuntimeStatus) {
|
||||
if allowLifecycleOverwrite {
|
||||
@@ -440,7 +448,10 @@ func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite
|
||||
}
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if _, onboardErr := runPicoclawOnboard(); onboardErr != nil {
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return lifecycleErr
|
||||
}
|
||||
if _, onboardErr := runPicoclawOnboardContext(ctx); onboardErr != nil {
|
||||
setStatus(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: true,
|
||||
@@ -455,6 +466,9 @@ func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
|
||||
}
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return lifecycleErr
|
||||
}
|
||||
if _, statErr := os.Stat(configPath); statErr == nil {
|
||||
goto configReady
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@ const ca = {
|
||||
enableConfirmDesc:
|
||||
'En activar MCP, PicoClaw s’aturarà i es tancarà qualsevol sessió activa de PicoClaw.',
|
||||
failed: 'L’operació MCP ha fallat',
|
||||
copyFailed: 'La còpia ha fallat. Copieu-ho manualment.',
|
||||
okBtn: 'Confirma',
|
||||
cancelBtn: 'Cancel·la'
|
||||
},
|
||||
@@ -599,7 +600,6 @@ const ca = {
|
||||
success: 'PicoClaw instal·lat correctament',
|
||||
failed: "No s'ha pogut instal·lar PicoClaw",
|
||||
uninstalling: "S'està desinstal·lant el temps d'execució...",
|
||||
uninstaling: "S'està desinstal·lant el temps d'execució...",
|
||||
uninstalled: "El temps d'execució s'ha desinstal·lat correctament.",
|
||||
uninstallFailed: 'La desinstal·lació ha fallat.',
|
||||
requiredTitle: 'PicoClaw no està instal·lat',
|
||||
|
||||
@@ -293,6 +293,7 @@ const cz = {
|
||||
enableConfirmDesc:
|
||||
'Povolením MCP se zastaví PicoClaw a ukončí se všechny aktivní relace PicoClaw.',
|
||||
failed: 'Operace MCP se nezdařila',
|
||||
copyFailed: 'Kopírování se nezdařilo. Zkopírujte ručně.',
|
||||
okBtn: 'Potvrdit',
|
||||
cancelBtn: 'Zrušit'
|
||||
},
|
||||
|
||||
@@ -290,6 +290,7 @@ const da = {
|
||||
enableConfirmDesc:
|
||||
'Aktivering af MCP stopper PicoClaw og lukker alle aktive PicoClaw-sessioner.',
|
||||
failed: 'MCP-handlingen mislykkedes',
|
||||
copyFailed: 'Kopiering mislykkedes. Kopiér manuelt.',
|
||||
okBtn: 'Bekræft',
|
||||
cancelBtn: 'Annuller'
|
||||
},
|
||||
|
||||
@@ -296,6 +296,7 @@ const de = {
|
||||
enableConfirmDesc:
|
||||
'Durch Aktivieren von MCP wird PicoClaw gestoppt und jede aktive PicoClaw-Sitzung geschlossen.',
|
||||
failed: 'MCP-Aktion fehlgeschlagen',
|
||||
copyFailed: 'Kopieren fehlgeschlagen. Bitte manuell kopieren.',
|
||||
okBtn: 'Bestätigen',
|
||||
cancelBtn: 'Abbrechen'
|
||||
},
|
||||
|
||||
@@ -293,6 +293,7 @@ const es = {
|
||||
enableConfirmDesc:
|
||||
'Al activar MCP se detendrá PicoClaw y se cerrará cualquier sesión activa de PicoClaw.',
|
||||
failed: 'Error en la operación MCP',
|
||||
copyFailed: 'Error al copiar. Copia manualmente.',
|
||||
okBtn: 'Confirmar',
|
||||
cancelBtn: 'Cancelar'
|
||||
},
|
||||
|
||||
@@ -295,6 +295,7 @@ const fr = {
|
||||
enableConfirmDesc:
|
||||
'L’activation de MCP arrêtera PicoClaw et fermera toute session PicoClaw active.',
|
||||
failed: 'Échec de l’opération MCP',
|
||||
copyFailed: 'La copie a échoué. Copiez manuellement.',
|
||||
okBtn: 'Confirmer',
|
||||
cancelBtn: 'Annuler'
|
||||
},
|
||||
|
||||
@@ -294,6 +294,7 @@ const hu = {
|
||||
enableConfirmDesc:
|
||||
'Az MCP engedélyezése leállítja a PicoClaw-t, és bezár minden aktív PicoClaw-munkamenetet.',
|
||||
failed: 'Az MCP-művelet sikertelen',
|
||||
copyFailed: 'A másolás sikertelen. Másolja kézzel.',
|
||||
okBtn: 'Megerősítés',
|
||||
cancelBtn: 'Mégse'
|
||||
},
|
||||
|
||||
@@ -292,6 +292,7 @@ const id = {
|
||||
enableConfirmDesc:
|
||||
'Mengaktifkan MCP akan menghentikan PicoClaw dan menutup semua sesi PicoClaw yang aktif.',
|
||||
failed: 'Operasi MCP gagal',
|
||||
copyFailed: 'Gagal menyalin. Salin secara manual.',
|
||||
okBtn: 'Konfirmasi',
|
||||
cancelBtn: 'Batal'
|
||||
},
|
||||
|
||||
@@ -294,6 +294,7 @@ const it = {
|
||||
enableConfirmDesc:
|
||||
'L’abilitazione di MCP arresterà PicoClaw e chiuderà tutte le sessioni PicoClaw attive.',
|
||||
failed: 'Operazione MCP non riuscita',
|
||||
copyFailed: 'Copia non riuscita. Copia manualmente.',
|
||||
okBtn: 'Conferma',
|
||||
cancelBtn: 'Annulla'
|
||||
},
|
||||
|
||||
@@ -293,6 +293,7 @@ const ja = {
|
||||
enableConfirmDesc:
|
||||
'MCP を有効にすると PicoClaw が停止し、アクティブな PicoClaw セッションがすべて終了します。',
|
||||
failed: 'MCP 操作に失敗しました',
|
||||
copyFailed: 'コピーに失敗しました。手動でコピーしてください。',
|
||||
okBtn: '確認',
|
||||
cancelBtn: 'キャンセル'
|
||||
},
|
||||
|
||||
@@ -288,6 +288,7 @@ const ko = {
|
||||
enableConfirmDesc:
|
||||
'MCP를 활성화하면 PicoClaw가 중지되고 활성 PicoClaw 세션이 모두 닫힙니다.',
|
||||
failed: 'MCP 작업에 실패했습니다',
|
||||
copyFailed: '복사에 실패했습니다. 수동으로 복사하세요.',
|
||||
okBtn: '확인',
|
||||
cancelBtn: '취소'
|
||||
},
|
||||
|
||||
@@ -291,6 +291,7 @@ const nb = {
|
||||
enableConfirmDesc:
|
||||
'Aktivering av MCP stopper PicoClaw og lukker alle aktive PicoClaw-økter.',
|
||||
failed: 'MCP-operasjonen mislyktes',
|
||||
copyFailed: 'Kopiering mislyktes. Kopier manuelt.',
|
||||
okBtn: 'Bekreft',
|
||||
cancelBtn: 'Avbryt'
|
||||
},
|
||||
|
||||
@@ -294,6 +294,7 @@ const nl = {
|
||||
enableConfirmDesc:
|
||||
'Als MCP wordt ingeschakeld, stopt PicoClaw en worden alle actieve PicoClaw-sessies gesloten.',
|
||||
failed: 'MCP-bewerking mislukt',
|
||||
copyFailed: 'Kopiëren mislukt. Kopieer handmatig.',
|
||||
okBtn: 'Bevestigen',
|
||||
cancelBtn: 'Annuleren'
|
||||
},
|
||||
|
||||
@@ -293,6 +293,7 @@ const pl = {
|
||||
enableConfirmDesc:
|
||||
'Włączenie MCP zatrzyma PicoClaw i zamknie wszystkie aktywne sesje PicoClaw.',
|
||||
failed: 'Operacja MCP nie powiodła się',
|
||||
copyFailed: 'Kopiowanie nie powiodło się. Skopiuj ręcznie.',
|
||||
okBtn: 'Potwierdź',
|
||||
cancelBtn: 'Anuluj'
|
||||
},
|
||||
|
||||
@@ -292,6 +292,7 @@ const pt_br = {
|
||||
enableConfirmDesc:
|
||||
'Habilitar o MCP interromperá o PicoClaw e fechará todas as sessões ativas do PicoClaw.',
|
||||
failed: 'Falha na operação MCP',
|
||||
copyFailed: 'Falha ao copiar. Copie manualmente.',
|
||||
okBtn: 'Confirmar',
|
||||
cancelBtn: 'Cancelar'
|
||||
},
|
||||
|
||||
@@ -292,6 +292,7 @@ const ru = {
|
||||
enableConfirmDesc:
|
||||
'Включение MCP остановит PicoClaw и закроет все активные сеансы PicoClaw.',
|
||||
failed: 'Операция MCP завершилась с ошибкой',
|
||||
copyFailed: 'Не удалось скопировать. Скопируйте вручную.',
|
||||
okBtn: 'Подтвердить',
|
||||
cancelBtn: 'Отмена'
|
||||
},
|
||||
|
||||
@@ -289,6 +289,7 @@ const se = {
|
||||
enableConfirmDesc:
|
||||
'Om MCP aktiveras stoppas PicoClaw och alla aktiva PicoClaw-sessioner stängs.',
|
||||
failed: 'MCP-åtgärden misslyckades',
|
||||
copyFailed: 'Kopiering misslyckades. Kopiera manuellt.',
|
||||
okBtn: 'Bekräfta',
|
||||
cancelBtn: 'Avbryt'
|
||||
},
|
||||
|
||||
@@ -285,6 +285,7 @@ const th = {
|
||||
enableConfirmDesc:
|
||||
'การเปิดใช้งาน MCP จะหยุด PicoClaw และปิดเซสชัน PicoClaw ที่ใช้งานอยู่ทั้งหมด',
|
||||
failed: 'การดำเนินการ MCP ล้มเหลว',
|
||||
copyFailed: 'คัดลอกไม่สำเร็จ โปรดคัดลอกด้วยตนเอง',
|
||||
okBtn: 'ยืนยัน',
|
||||
cancelBtn: 'ยกเลิก'
|
||||
},
|
||||
|
||||
@@ -291,6 +291,7 @@ const tr = {
|
||||
enableConfirmDesc:
|
||||
'MCP etkinleştirildiğinde PicoClaw durdurulur ve tüm etkin PicoClaw oturumları kapatılır.',
|
||||
failed: 'MCP işlemi başarısız oldu',
|
||||
copyFailed: 'Kopyalama başarısız. Elle kopyalayın.',
|
||||
okBtn: 'Onayla',
|
||||
cancelBtn: 'İptal'
|
||||
},
|
||||
|
||||
@@ -292,6 +292,7 @@ const uk = {
|
||||
enableConfirmTitle: 'Увімкнути зовнішнє керування MCP?',
|
||||
enableConfirmDesc: 'Увімкнення MCP зупинить PicoClaw і закриє всі активні сеанси PicoClaw.',
|
||||
failed: 'Операція MCP завершилася помилкою',
|
||||
copyFailed: 'Не вдалося скопіювати. Скопіюйте вручну.',
|
||||
okBtn: 'Підтвердити',
|
||||
cancelBtn: 'Скасувати'
|
||||
},
|
||||
|
||||
@@ -289,6 +289,7 @@ const vi = {
|
||||
enableConfirmTitle: 'Bật điều khiển MCP bên ngoài?',
|
||||
enableConfirmDesc: 'Bật MCP sẽ dừng PicoClaw và đóng tất cả phiên PicoClaw đang hoạt động.',
|
||||
failed: 'Thao tác MCP không thành công',
|
||||
copyFailed: 'Sao chép thất bại. Vui lòng sao chép thủ công.',
|
||||
okBtn: 'Xác nhận',
|
||||
cancelBtn: 'Hủy'
|
||||
},
|
||||
|
||||
@@ -379,6 +379,12 @@ export function usePicoclawSidebarLifecycle({
|
||||
setTransportState,
|
||||
setRunState
|
||||
}: LifecycleOptions) {
|
||||
const tRef = useRef(t);
|
||||
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -396,7 +402,7 @@ export function usePicoclawSidebarLifecycle({
|
||||
if (nextRuntimeStatus?.installing) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.install.installing'))
|
||||
createStatusMessage(tRef.current('picoclaw.install.installing'))
|
||||
]);
|
||||
return;
|
||||
}
|
||||
@@ -404,7 +410,7 @@ export function usePicoclawSidebarLifecycle({
|
||||
if (nextRuntimeStatus?.installed === false) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.install.requiredDescription'))
|
||||
createStatusMessage(tRef.current('picoclaw.install.requiredDescription'))
|
||||
]);
|
||||
return;
|
||||
}
|
||||
@@ -412,7 +418,7 @@ export function usePicoclawSidebarLifecycle({
|
||||
if (nextRuntimeStatus?.model_configured === false) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.model.requiredDescription'))
|
||||
createStatusMessage(tRef.current('picoclaw.model.requiredDescription'))
|
||||
]);
|
||||
return;
|
||||
}
|
||||
@@ -423,7 +429,7 @@ export function usePicoclawSidebarLifecycle({
|
||||
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.status.connecting'))
|
||||
createStatusMessage(tRef.current('picoclaw.status.connecting'))
|
||||
]);
|
||||
try {
|
||||
await connectGateway(nextSessionId);
|
||||
@@ -460,8 +466,7 @@ export function usePicoclawSidebarLifecycle({
|
||||
setOverlay,
|
||||
setRunState,
|
||||
setTakeover,
|
||||
setTransportState,
|
||||
t
|
||||
setTransportState
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user