,
blockId: string,
@@ -217,7 +172,6 @@ const BlockFrame_Header = ({
let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view);
const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton);
const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader);
- const termDurableStatus = util.useAtomValueSafe(viewModel?.termDurableStatus);
const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable);
const hideViewName = util.useAtomValueSafe(viewModel?.hideViewName);
const magnified = jotai.useAtomValue(nodeModel.isMagnified);
@@ -227,8 +181,6 @@ const BlockFrame_Header = ({
const isTerminalBlock = blockData?.meta?.view === "term";
viewName = blockData?.meta?.["frame:title"] ?? viewName;
viewIconUnion = blockData?.meta?.["frame:icon"] ?? viewIconUnion;
- const connName = blockData?.meta?.connection;
- const connStatus = jotai.useAtomValue(getConnStatusAtom(connName));
React.useEffect(() => {
if (magnified && !preview && !prevMagifiedState.current) {
@@ -240,12 +192,6 @@ const BlockFrame_Header = ({
const viewIconElem = getViewIconElem(viewIconUnion, blockData);
- const { color: durableIconColor, titleText: durableTitle, iconType: durableIconType } = getDurableIconProps(
- termDurableStatus,
- connStatus,
- termConfigedDurable
- );
-
return (
)}
{useTermHeader && termConfigedDurable != null && (
-
-
-
+
)}
diff --git a/frontend/app/block/durable-session-flyover.tsx b/frontend/app/block/durable-session-flyover.tsx
new file mode 100644
index 0000000000..06626723fa
--- /dev/null
+++ b/frontend/app/block/durable-session-flyover.tsx
@@ -0,0 +1,438 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { getApi, getConnStatusAtom, recordTEvent, WOS } from "@/app/store/global";
+import { TermViewModel } from "@/app/view/term/term-model";
+import * as util from "@/util/util";
+import { cn } from "@/util/util";
+import {
+ autoUpdate,
+ flip,
+ FloatingPortal,
+ offset,
+ safePolygon,
+ shift,
+ useFloating,
+ useHover,
+ useInteractions,
+} from "@floating-ui/react";
+import * as jotai from "jotai";
+import { useEffect, useRef, useState } from "react";
+
+function isTermViewModel(viewModel: ViewModel): viewModel is TermViewModel {
+ return viewModel?.viewType === "term";
+}
+
+function handleLearnMore() {
+ getApi().openExternal("https://docs.waveterm.dev/features/durable-sessions");
+}
+
+function LearnMoreButton() {
+ return (
+
+ Learn More
+
+ );
+}
+
+interface StandardSessionContentProps {
+ viewModel: TermViewModel;
+ onClose: () => void;
+}
+
+function StandardSessionContent({ viewModel, onClose }: StandardSessionContentProps) {
+ const handleRestartAsDurable = () => {
+ recordTEvent("action:termdurable", { "action:type": "restartdurable" });
+ onClose();
+ util.fireAndForget(() => viewModel.restartSessionWithDurability(true));
+ };
+
+ return (
+
+
+
+ Standard SSH Session
+
+
+ Standard SSH sessions end when the connection drops. Durable sessions keep your shell state, running
+ programs, and history alive through network changes, computer sleep, and Wave restarts.
+
+
+
+ Restart as Durable
+
+
+
+ );
+}
+
+interface DurableAttachedContentProps {
+ onClose: () => void;
+}
+
+function DurableAttachedContent({ onClose }: DurableAttachedContentProps) {
+ return (
+
+
+
+ Durable Session (Attached)
+
+
+ Your shell state, running programs, and history are protected. This session will survive network
+ disconnects.
+
+
+
+ );
+}
+
+interface DurableDetachedContentProps {
+ onClose: () => void;
+}
+
+function DurableDetachedContent({ onClose }: DurableDetachedContentProps) {
+ return (
+
+
+
+ Durable Session (Detached)
+
+
+ Connection lost, but your session is still running on the remote server. Wave will automatically
+ reconnect when the connection is restored.
+
+
+
+ );
+}
+
+interface DurableAwaitingStartProps {
+ connected: boolean;
+ viewModel: TermViewModel;
+ onClose: () => void;
+}
+
+function DurableAwaitingStart({ connected, viewModel, onClose }: DurableAwaitingStartProps) {
+ const handleStartSession = () => {
+ onClose();
+ util.fireAndForget(() => viewModel.forceRestartController());
+ };
+
+ if (!connected) {
+ return (
+
+
+
+ Durable Session (Awaiting Connection)
+
+
+ Configured for a durable session. The session will start when the connection is established.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Durable Session (Awaiting Start)
+
+
+ Configured for a durable session, but session hasn't started yet. Click below to start it manually.
+
+
+
+ Start Session
+
+
+
+ );
+}
+
+interface DurableStartingContentProps {
+ onClose: () => void;
+}
+
+function DurableStartingContent({ onClose }: DurableStartingContentProps) {
+ return (
+
+
+
+ Durable Session (Starting)
+
+
The durable session is starting.
+
+
+ );
+}
+
+interface DurableEndedContentProps {
+ doneReason: string;
+ startupError?: string;
+ viewModel: TermViewModel;
+ onClose: () => void;
+}
+
+function DurableEndedContent({ doneReason, startupError, viewModel, onClose }: DurableEndedContentProps) {
+ const handleRestartSession = () => {
+ onClose();
+ util.fireAndForget(() => viewModel.forceRestartController());
+ };
+
+ const handleRestartAsStandard = () => {
+ onClose();
+ util.fireAndForget(() => viewModel.restartSessionWithDurability(false));
+ };
+
+ let titleText = "Durable Session (Ended)";
+ let descriptionText = "The durable session has ended. This block is still configured for durable sessions.";
+ let showRestartButton = true;
+
+ if (doneReason === "terminated") {
+ titleText = "Durable Session (Ended, Exited)";
+ descriptionText =
+ "The shell was terminated and is no longer running. This block is still configured for durable sessions.";
+ } else if (doneReason === "gone") {
+ titleText = "Durable Session (Ended, Lost)";
+ descriptionText =
+ "The session was lost or not found on the remote server. This may have occurred due to a system reboot or the session being manually terminated.";
+ } else if (doneReason === "startuperror") {
+ titleText = "Durable Session (Failed to Start)";
+ descriptionText = "The durable session failed to start.";
+ return (
+
+
+
+ {titleText}
+
+
{descriptionText}
+ {startupError && (
+
+ {startupError}
+
+ )}
+
+
+ Restart Session
+
+
+
+ Restart as Standard
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {titleText}
+
+
{descriptionText}
+ {showRestartButton && (
+
+
+ Restart Session
+
+ )}
+
+
+ );
+}
+
+function getContentToRender(
+ viewModel: TermViewModel,
+ onClose: () => void,
+ jobStatus: BlockJobStatusData,
+ connStatus: ConnStatus,
+ isConfigedDurable?: boolean | null
+): string | React.ReactNode {
+ if (isConfigedDurable === false) {
+ return ;
+ }
+
+ const status = jobStatus?.status;
+ if (status === "connected") {
+ return ;
+ } else if (status === "disconnected") {
+ return ;
+ } else if (status === "init") {
+ return ;
+ } else if (status === "done") {
+ const doneReason = jobStatus?.donereason;
+ const startupError = jobStatus?.startuperror;
+ return (
+
+ );
+ } else if (status == null) {
+ return ;
+ }
+ console.log("DurableSessionFlyover: unexpected jobStatus", jobStatus);
+ return null;
+}
+
+function getIconProps(jobStatus: BlockJobStatusData, connStatus: ConnStatus, isConfigedDurable?: boolean | null) {
+ let color = "text-muted";
+ let iconType: "fa-solid" | "fa-regular" = "fa-solid";
+
+ if (isConfigedDurable === false) {
+ color = "text-muted";
+ iconType = "fa-regular";
+ return { color, iconType };
+ }
+
+ const status = jobStatus?.status;
+ if (status === "connected") {
+ color = "text-sky-500";
+ } else if (status === "disconnected") {
+ color = "text-sky-300";
+ } else if (status === "init") {
+ color = "text-sky-300";
+ } else if (status === "done") {
+ color = "text-muted";
+ } else if (status == null) {
+ color = "text-muted";
+ }
+ return { color, iconType };
+}
+
+interface DurableSessionFlyoverProps {
+ blockId: string;
+ viewModel: ViewModel;
+ placement?: "top" | "bottom" | "left" | "right";
+ divClassName?: string;
+}
+
+export function DurableSessionFlyover({
+ blockId,
+ viewModel,
+ placement = "bottom",
+ divClassName,
+}: DurableSessionFlyoverProps) {
+ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId));
+ const termDurableStatus = util.useAtomValueSafe(viewModel?.termDurableStatus);
+ const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable);
+ const connName = blockData?.meta?.connection;
+ const connStatus = jotai.useAtomValue(getConnStatusAtom(connName));
+
+ const { color: durableIconColor, iconType: durableIconType } = getIconProps(
+ termDurableStatus,
+ connStatus,
+ termConfigedDurable
+ );
+
+ const [isOpen, setIsOpen] = useState(false);
+ const [isVisible, setIsVisible] = useState(false);
+ const timeoutRef = useRef(null);
+
+ const handleClose = () => {
+ setIsVisible(false);
+ if (timeoutRef.current !== null) {
+ window.clearTimeout(timeoutRef.current);
+ }
+ timeoutRef.current = window.setTimeout(() => {
+ setIsOpen(false);
+ }, 300);
+ };
+
+ const { refs, floatingStyles, context } = useFloating({
+ open: isOpen,
+ onOpenChange: (open) => {
+ if (open) {
+ setIsOpen(true);
+ if (timeoutRef.current !== null) {
+ window.clearTimeout(timeoutRef.current);
+ }
+ timeoutRef.current = window.setTimeout(() => {
+ setIsVisible(true);
+ }, 300);
+ } else {
+ setIsVisible(false);
+ if (timeoutRef.current !== null) {
+ window.clearTimeout(timeoutRef.current);
+ }
+ timeoutRef.current = window.setTimeout(() => {
+ setIsOpen(false);
+ }, 300);
+ }
+ },
+ placement,
+ middleware: [offset(10), flip(), shift({ padding: 12 })],
+ whileElementsMounted: autoUpdate,
+ });
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current !== null) {
+ window.clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ const hover = useHover(context, {
+ handleClose: safePolygon(),
+ });
+ const { getReferenceProps, getFloatingProps } = useInteractions([hover]);
+
+ if (!isTermViewModel(viewModel)) {
+ return null;
+ }
+
+ const content = getContentToRender(viewModel, handleClose, termDurableStatus, connStatus, termConfigedDurable);
+ if (content == null) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+ {isOpen && (
+
+ e.stopPropagation()}
+ onFocusCapture={(e) => e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ >
+ {content}
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/app/element/tooltip.tsx b/frontend/app/element/tooltip.tsx
index 3086e5673f..40677a172a 100644
--- a/frontend/app/element/tooltip.tsx
+++ b/frontend/app/element/tooltip.tsx
@@ -20,6 +20,7 @@ interface TooltipProps {
placement?: "top" | "bottom" | "left" | "right";
forceOpen?: boolean;
disable?: boolean;
+ openDelay?: number;
divClassName?: string;
divStyle?: React.CSSProperties;
divOnClick?: (e: React.MouseEvent) => void;
@@ -30,6 +31,7 @@ function TooltipInner({
content,
placement = "top",
forceOpen = false,
+ openDelay = 300,
divClassName,
divStyle,
divOnClick,
@@ -52,7 +54,7 @@ function TooltipInner({
}
timeoutRef.current = window.setTimeout(() => {
setIsVisible(true);
- }, 300);
+ }, openDelay);
} else {
setIsVisible(false);
if (timeoutRef.current !== null) {
@@ -146,6 +148,7 @@ export function Tooltip({
placement = "top",
forceOpen = false,
disable = false,
+ openDelay = 300,
divClassName,
divStyle,
divOnClick,
@@ -164,6 +167,7 @@ export function Tooltip({
content={content}
placement={placement}
forceOpen={forceOpen}
+ openDelay={openDelay}
divClassName={divClassName}
divStyle={divStyle}
divOnClick={divOnClick}
diff --git a/frontend/app/modals/conntypeahead.tsx b/frontend/app/modals/conntypeahead.tsx
index 1743f32dc9..95cf831e24 100644
--- a/frontend/app/modals/conntypeahead.tsx
+++ b/frontend/app/modals/conntypeahead.tsx
@@ -116,7 +116,8 @@ function createFilteredLocalSuggestionItem(
function getReconnectItem(
connStatus: ConnStatus,
connSelected: string,
- blockId: string
+ blockId: string,
+ changeConnModalAtom: jotai.PrimitiveAtom
): SuggestionConnectionItem | null {
if (connSelected != "" || (connStatus.status != "disconnected" && connStatus.status != "error")) {
return null;
@@ -128,6 +129,7 @@ function getReconnectItem(
label: `Reconnect to ${connStatus.connection}`,
value: "",
onSelect: async (_: string) => {
+ globalStore.set(changeConnModalAtom, false);
const prtn = RpcApi.ConnConnectCommand(
TabRpcClient,
{ host: connStatus.connection, logblockid: blockId },
@@ -200,7 +202,8 @@ function getRemoteSuggestions(
function getDisconnectItem(
connection: string,
- connStatusMap: Map
+ connStatusMap: Map,
+ changeConnModalAtom: jotai.PrimitiveAtom
): SuggestionConnectionItem | null {
if (util.isLocalConnName(connection)) {
return null;
@@ -216,6 +219,7 @@ function getDisconnectItem(
label: `Disconnect ${connStatus.connection}`,
value: "",
onSelect: async (_: string) => {
+ globalStore.set(changeConnModalAtom, false);
const prtn = RpcApi.ConnDisconnectCommand(TabRpcClient, connection, { timeout: 60000 });
prtn.catch((e) => console.log("error disconnecting", connStatus.connection, e));
},
@@ -371,7 +375,7 @@ const ChangeConnectionBlockModal = React.memo(
[blockId, blockData]
);
- const reconnectSuggestionItem = getReconnectItem(connStatus, connSelected, blockId);
+ const reconnectSuggestionItem = getReconnectItem(connStatus, connSelected, blockId, changeConnModalAtom);
const localSuggestions = getLocalSuggestions(
localName,
wslList,
@@ -391,7 +395,7 @@ const ChangeConnectionBlockModal = React.memo(
filterOutNowsh
);
const connectionsEditItem = getConnectionsEditItem(changeConnModalAtom, connSelected);
- const disconnectItem = getDisconnectItem(connection, connStatusMap);
+ const disconnectItem = getDisconnectItem(connection, connStatusMap, changeConnModalAtom);
const newConnectionSuggestionItem = getNewConnectionSuggestionItem(
connSelected,
localName,
diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts
index f0a23fcdd7..b54c41d12f 100644
--- a/frontend/app/view/term/term-model.ts
+++ b/frontend/app/view/term/term-model.ts
@@ -1147,7 +1147,7 @@ export class TermViewModel implements ViewModel {
submenu: [
{
label: "Restart Session in Standard Mode",
- click: () => this.restartSessionWithDurability(false),
+ click: () => fireAndForget(() => this.restartSessionWithDurability(false)),
},
],
});
@@ -1157,7 +1157,7 @@ export class TermViewModel implements ViewModel {
submenu: [
{
label: "Restart Session in Durable Mode",
- click: () => this.restartSessionWithDurability(true),
+ click: () => fireAndForget(() => this.restartSessionWithDurability(true)),
},
],
});
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts
index b2a7f9a7ff..0b05a8cf29 100644
--- a/frontend/types/gotypes.d.ts
+++ b/frontend/types/gotypes.d.ts
@@ -144,9 +144,10 @@ declare global {
type BlockJobStatusData = {
blockid: string;
jobid: string;
- status: null | "init" | "connected" | "disconnected" | "done";
+ status?: null | "init" | "connected" | "disconnected" | "done";
versionts: number;
donereason?: string;
+ startuperror?: string;
cmdexitts?: number;
cmdexitcode?: number;
cmdexitsignal?: string;
@@ -930,6 +931,7 @@ declare global {
cmdenv?: {[key: string]: string};
jobauthtoken: string;
attachedblockid?: string;
+ waveversion?: string;
terminateonreconnect?: boolean;
jobmanagerstatus: string;
jobmanagerdonereason?: string;
diff --git a/package-lock.json b/package-lock.json
index d4d419d317..668621913b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "waveterm",
- "version": "0.13.2-alpha.0",
+ "version": "0.13.2-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "waveterm",
- "version": "0.13.2-alpha.0",
+ "version": "0.13.2-alpha.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
diff --git a/package.json b/package.json
index da31edbf17..9ef857a985 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"productName": "Wave",
"description": "Open-Source AI-Native Terminal Built for Seamless Workflows",
"license": "Apache-2.0",
- "version": "0.13.2-alpha.0",
+ "version": "0.13.2-alpha.1",
"homepage": "https://waveterm.dev",
"build": {
"appId": "dev.commandline.waveterm"
diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go
index cfd5623868..d5b307e92a 100644
--- a/pkg/blockcontroller/blockcontroller.go
+++ b/pkg/blockcontroller/blockcontroller.go
@@ -69,6 +69,7 @@ type Controller interface {
Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error
Stop(graceful bool, newStatus string, destroy bool)
GetRuntimeStatus() *BlockControllerRuntimeStatus // does not return nil
+ GetConnName() string
SendInput(input *BlockInputUnion) error
}
@@ -157,9 +158,9 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
// Check for connection change FIRST - always destroy on conn change
if existing != nil {
- existingStatus := existing.GetRuntimeStatus()
- if existingStatus.ShellProcConnName != connName {
- log.Printf("stopping blockcontroller %s due to conn change (from %q to %q)\n", blockId, existingStatus.ShellProcConnName, connName)
+ existingConnName := existing.GetConnName()
+ if existingConnName != connName {
+ log.Printf("stopping blockcontroller %s due to conn change (from %q to %q)\n", blockId, existingConnName, connName)
DestroyBlockController(blockId)
time.Sleep(100 * time.Millisecond)
existing = nil
@@ -233,14 +234,14 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts
switch controllerName {
case BlockController_Shell, BlockController_Cmd:
if shouldUseDurableShellController {
- controller = MakeDurableShellController(tabId, blockId, controllerName)
+ controller = MakeDurableShellController(tabId, blockId, controllerName, connName)
} else {
- controller = MakeShellController(tabId, blockId, controllerName)
+ controller = MakeShellController(tabId, blockId, controllerName, connName)
}
registerController(blockId, controller)
case BlockController_Tsunami:
- controller = MakeTsunamiController(tabId, blockId)
+ controller = MakeTsunamiController(tabId, blockId, connName)
registerController(blockId, controller)
default:
diff --git a/pkg/blockcontroller/durableshellcontroller.go b/pkg/blockcontroller/durableshellcontroller.go
index 7e9b94966b..25ac22c9aa 100644
--- a/pkg/blockcontroller/durableshellcontroller.go
+++ b/pkg/blockcontroller/durableshellcontroller.go
@@ -33,6 +33,7 @@ type DurableShellController struct {
ControllerType string
TabId string
BlockId string
+ ConnName string
BlockDef *waveobj.BlockDef
VersionTs utilds.VersionTs
@@ -40,16 +41,16 @@ type DurableShellController struct {
inputSeqNum int // monotonic sequence number for inputs, starts at 1
JobId string
- ConnName string
LastKnownStatus string
}
-func MakeDurableShellController(tabId string, blockId string, controllerType string) Controller {
+func MakeDurableShellController(tabId string, blockId string, controllerType string, connName string) Controller {
return &DurableShellController{
Lock: &sync.Mutex{},
ControllerType: controllerType,
TabId: tabId,
BlockId: blockId,
+ ConnName: connName,
LastKnownStatus: Status_Init,
InputSessionId: uuid.New().String(),
}
@@ -105,6 +106,12 @@ func (dsc *DurableShellController) GetRuntimeStatus() *BlockControllerRuntimeSta
return &rtn
}
+func (dsc *DurableShellController) GetConnName() string {
+ dsc.Lock.Lock()
+ defer dsc.Lock.Unlock()
+ return dsc.ConnName
+}
+
func (dsc *DurableShellController) sendUpdate_withlock() {
rtStatus := dsc.getRuntimeStatus_withlock()
log.Printf("sending blockcontroller update %#v\n", rtStatus)
@@ -133,8 +140,7 @@ func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj.
return fmt.Errorf("error getting block: %w", err)
}
- connName := blockMeta.GetString(waveobj.MetaKey_Connection, "")
- if conncontroller.IsLocalConnName(connName) {
+ if conncontroller.IsLocalConnName(dsc.ConnName) {
return fmt.Errorf("durable shell controller requires a remote connection")
}
@@ -157,7 +163,7 @@ func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj.
if jobId == "" {
log.Printf("block %q starting new durable shell\n", dsc.BlockId)
- newJobId, err := dsc.startNewJob(ctx, blockMeta, connName)
+ newJobId, err := dsc.startNewJob(ctx, blockMeta, dsc.ConnName)
if err != nil {
return fmt.Errorf("failed to start new job: %w", err)
}
@@ -166,7 +172,6 @@ func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj.
dsc.WithLock(func() {
dsc.JobId = jobId
- dsc.ConnName = connName
dsc.sendUpdate_withlock()
})
diff --git a/pkg/blockcontroller/shellcontroller.go b/pkg/blockcontroller/shellcontroller.go
index 6dead6e788..b0c7081efc 100644
--- a/pkg/blockcontroller/shellcontroller.go
+++ b/pkg/blockcontroller/shellcontroller.go
@@ -55,6 +55,7 @@ type ShellController struct {
ControllerType string
TabId string
BlockId string
+ ConnName string
BlockDef *waveobj.BlockDef
RunLock *atomic.Bool
ProcStatus string
@@ -67,12 +68,13 @@ type ShellController struct {
}
// Constructor that returns the Controller interface
-func MakeShellController(tabId string, blockId string, controllerType string) Controller {
+func MakeShellController(tabId string, blockId string, controllerType string, connName string) Controller {
return &ShellController{
Lock: &sync.Mutex{},
ControllerType: controllerType,
TabId: tabId,
BlockId: blockId,
+ ConnName: connName,
ProcStatus: Status_Init,
RunLock: &atomic.Bool{},
}
@@ -122,9 +124,7 @@ func (sc *ShellController) getRuntimeStatus_nolock() BlockControllerRuntimeStatu
rtn.Version = sc.VersionTs.GetVersionTs()
rtn.BlockId = sc.BlockId
rtn.ShellProcStatus = sc.ProcStatus
- if sc.ShellProc != nil {
- rtn.ShellProcConnName = sc.ShellProc.ConnName
- }
+ rtn.ShellProcConnName = sc.ConnName
rtn.ShellProcExitCode = sc.ProcExitCode
return rtn
}
@@ -137,6 +137,10 @@ func (sc *ShellController) GetRuntimeStatus() *BlockControllerRuntimeStatus {
return &rtn
}
+func (sc *ShellController) GetConnName() string {
+ return sc.ConnName
+}
+
func (sc *ShellController) SendInput(inputUnion *BlockInputUnion) error {
var shellInputCh chan *BlockInputUnion
sc.WithLock(func() {
diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go
index af69edd5c5..d064d87998 100644
--- a/pkg/blockcontroller/tsunamicontroller.go
+++ b/pkg/blockcontroller/tsunamicontroller.go
@@ -39,6 +39,7 @@ type TsunamiAppProc struct {
type TsunamiController struct {
blockId string
tabId string
+ connName string
runLock sync.Mutex
tsunamiProc *TsunamiAppProc
statusLock sync.Mutex
@@ -271,6 +272,7 @@ func (c *TsunamiController) GetRuntimeStatus() *BlockControllerRuntimeStatus {
BlockId: c.blockId,
Version: c.versionTs.GetVersionTs(),
ShellProcStatus: c.status,
+ ShellProcConnName: c.connName,
ShellProcExitCode: c.exitCode,
}
@@ -282,6 +284,10 @@ func (c *TsunamiController) GetRuntimeStatus() *BlockControllerRuntimeStatus {
return rtn
}
+func (c *TsunamiController) GetConnName() string {
+ return c.connName
+}
+
func (c *TsunamiController) SendInput(input *BlockInputUnion) error {
return fmt.Errorf("tsunami controller send input not implemented")
}
@@ -399,12 +405,13 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string,
}
}
-func MakeTsunamiController(tabId string, blockId string) Controller {
+func MakeTsunamiController(tabId string, blockId string, connName string) Controller {
log.Printf("make tsunami controller: %s %s\n", tabId, blockId)
return &TsunamiController{
- blockId: blockId,
- tabId: tabId,
- status: Status_Init,
+ blockId: blockId,
+ tabId: tabId,
+ connName: connName,
+ status: Status_Init,
}
}
diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go
index c0b1680453..59f88c100a 100644
--- a/pkg/jobcontroller/jobcontroller.go
+++ b/pkg/jobcontroller/jobcontroller.go
@@ -25,6 +25,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/util/shellutil"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/utilds"
+ "github.com/wavetermdev/waveterm/pkg/wavebase"
"github.com/wavetermdev/waveterm/pkg/wavejwt"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wconfig"
@@ -60,6 +61,8 @@ const (
const DefaultStreamRwnd = 64 * 1024
const MetaKey_TotalGap = "totalgap"
const JobOutputFileName = "term"
+const AutoReconnectDelay = 1 * time.Second
+const AutoReconnectCooldown = 30 * time.Second
type connState struct {
actual bool
@@ -93,10 +96,39 @@ var (
jobTerminationMessageWritten = ds.MakeSyncMap[bool]()
+ lastAutoReconnectAttempt = ds.MakeSyncMap[int64]()
+
reconnectGroup singleflight.Group
terminateJobManagerGroup singleflight.Group
)
+func InitJobController() {
+ go connReconcileWorker()
+ go jobPruningWorker()
+
+ rpcClient := wshclient.GetBareRpcClient()
+ rpcClient.EventListener.On(wps.Event_RouteUp, handleRouteUpEvent)
+ rpcClient.EventListener.On(wps.Event_RouteDown, handleRouteDownEvent)
+ rpcClient.EventListener.On(wps.Event_ConnChange, handleConnChangeEvent)
+ rpcClient.EventListener.On(wps.Event_BlockClose, handleBlockCloseEvent)
+ wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{
+ Event: wps.Event_RouteUp,
+ AllScopes: true,
+ }, nil)
+ wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{
+ Event: wps.Event_RouteDown,
+ AllScopes: true,
+ }, nil)
+ wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{
+ Event: wps.Event_ConnChange,
+ AllScopes: true,
+ }, nil)
+ wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{
+ Event: wps.Event_BlockClose,
+ AllScopes: true,
+ }, nil)
+}
+
func isJobManagerRunning(job *waveobj.Job) bool {
return job.JobManagerStatus == JobManagerStatus_Running
}
@@ -157,6 +189,7 @@ func GetBlockJobStatus(ctx context.Context, blockId string) (*wshrpc.BlockJobSta
data.JobId = job.OID
data.DoneReason = job.JobManagerDoneReason
+ data.StartupError = job.JobManagerStartupError
data.CmdExitTs = job.CmdExitTs
data.CmdExitCode = job.CmdExitCode
data.CmdExitSignal = job.CmdExitSignal
@@ -260,33 +293,6 @@ func getMetaInt64(meta wshrpc.FileMeta, key string) int64 {
return 0
}
-func InitJobController() {
- go connReconcileWorker()
- go jobPruningWorker()
-
- rpcClient := wshclient.GetBareRpcClient()
- rpcClient.EventListener.On(wps.Event_RouteUp, handleRouteUpEvent)
- rpcClient.EventListener.On(wps.Event_RouteDown, handleRouteDownEvent)
- rpcClient.EventListener.On(wps.Event_ConnChange, handleConnChangeEvent)
- rpcClient.EventListener.On(wps.Event_BlockClose, handleBlockCloseEvent)
- wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{
- Event: wps.Event_RouteUp,
- AllScopes: true,
- }, nil)
- wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{
- Event: wps.Event_RouteDown,
- AllScopes: true,
- }, nil)
- wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{
- Event: wps.Event_ConnChange,
- AllScopes: true,
- }, nil)
- wshclient.EventSubCommand(rpcClient, wps.SubscriptionRequest{
- Event: wps.Event_BlockClose,
- AllScopes: true,
- }, nil)
-}
-
func jobPruningWorker() {
defer func() {
panichandler.PanicHandler("jobcontroller:jobPruningWorker", recover())
@@ -319,7 +325,9 @@ func pruneUnusedJobs(previousCandidates []string) []string {
}
jobsToDelete := utilfn.StrSetIntersection(previousCandidates, currentCandidates)
- log.Printf("[jobpruner] prev=%d current=%d deleting=%d", len(previousCandidates), len(currentCandidates), len(jobsToDelete))
+ if len(previousCandidates) > 0 || len(currentCandidates) > 0 {
+ log.Printf("[jobpruner] prev=%d current=%d deleting=%d", len(previousCandidates), len(currentCandidates), len(jobsToDelete))
+ }
for _, jobId := range jobsToDelete {
err := DeleteJob(ctx, jobId)
@@ -353,10 +361,58 @@ func handleRouteEvent(event *wps.WaveEvent, newStatus string) {
continue
}
sendBlockJobStatusEventByJob(ctx, job)
+
+ if newStatus == JobConnStatus_Disconnected && job != nil && isJobManagerRunning(job) {
+ if shouldAttemptAutoReconnect(jobId) {
+ go attemptAutoReconnect(jobId, job.Connection)
+ }
+ }
}
}
}
+func shouldAttemptAutoReconnect(jobId string) bool {
+ now := time.Now().Unix()
+ lastAttempt, exists := lastAutoReconnectAttempt.GetEx(jobId)
+
+ if !exists {
+ lastAutoReconnectAttempt.Set(jobId, now)
+ return true
+ }
+
+ timeSinceLastAttempt := time.Duration(now-lastAttempt) * time.Second
+ if timeSinceLastAttempt >= AutoReconnectCooldown {
+ lastAutoReconnectAttempt.Set(jobId, now)
+ return true
+ }
+
+ return false
+}
+
+func attemptAutoReconnect(jobId string, connName string) {
+ defer func() {
+ panichandler.PanicHandler("jobcontroller:attemptAutoReconnect", recover())
+ }()
+
+ time.Sleep(AutoReconnectDelay)
+
+ isConnected, err := conncontroller.IsConnected(connName)
+ if err != nil || !isConnected {
+ log.Printf("[job:%s] connection %s is down, skipping auto-reconnect", jobId, connName)
+ return
+ }
+
+ log.Printf("[job:%s] connection %s still up after route down, attempting auto-reconnect to determine job manager status", jobId, connName)
+ ctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancelFn()
+ err = ReconnectJob(ctx, jobId, nil)
+ if err != nil {
+ log.Printf("[job:%s] auto-reconnect failed: %v", jobId, err)
+ } else {
+ log.Printf("[job:%s] auto-reconnect succeeded", jobId)
+ }
+}
+
func handleConnChangeEvent(event *wps.WaveEvent) {
var connStatus wshrpc.ConnStatus
err := utilfn.ReUnmarshal(&connStatus, event.Data)
@@ -562,6 +618,7 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) {
JobAuthToken: jobAuthToken,
JobManagerStatus: JobManagerStatus_Init,
AttachedBlockId: params.BlockId,
+ WaveVersion: wavebase.WaveVersion,
Meta: make(waveobj.MetaMapType),
}
@@ -716,12 +773,9 @@ func runOutputLoop(ctx context.Context, jobId string, streamId string, reader *s
break
}
if n > 0 {
- log.Printf("[job:%s] received %d bytes of data", jobId, n)
appendErr := handleAppendJobFile(ctx, jobId, JobOutputFileName, buf[:n])
if appendErr != nil {
log.Printf("[job:%s] error appending data to WaveFS: %v", jobId, appendErr)
- } else {
- log.Printf("[job:%s] successfully appended %d bytes to WaveFS", jobId, n)
}
}
@@ -1011,6 +1065,7 @@ func doReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOp
} else {
sendBlockJobStatusEventByJob(ctx, updatedJob)
}
+ writeJobTerminationMessage(ctx, jobId, updatedJob, "[session gone]")
return fmt.Errorf("job manager has exited: %s", rtnData.Error)
}
return fmt.Errorf("failed to reconnect to job manager: %s", rtnData.Error)
@@ -1136,19 +1191,13 @@ func restartStreaming(ctx context.Context, jobId string, knownConnected bool, rt
exitCodeStr = fmt.Sprintf("%d", *rtnData.ExitCode)
}
log.Printf("[job:%s] job has already exited: code=%s signal=%q err=%q", jobId, exitCodeStr, rtnData.ExitSignal, rtnData.ExitErr)
- var updatedJob *waveobj.Job
- updateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {
- job.JobManagerStatus = JobManagerStatus_Done
- job.CmdExitCode = rtnData.ExitCode
- job.CmdExitSignal = rtnData.ExitSignal
- job.CmdExitError = rtnData.ExitErr
- updatedJob = job
- })
- if updateErr != nil {
- log.Printf("[job:%s] error updating job exit status: %v", jobId, updateErr)
- } else {
- sendBlockJobStatusEventByJob(ctx, updatedJob)
+ exitData := wshrpc.CommandJobCmdExitedData{
+ ExitCode: rtnData.ExitCode,
+ ExitSignal: rtnData.ExitSignal,
+ ExitErr: rtnData.ExitErr,
+ ExitTs: time.Now().UnixMilli(),
}
+ HandleCmdJobExited(ctx, jobId, exitData)
}
if rtnData.StreamDone {
@@ -1469,3 +1518,16 @@ func writeMutedMessageToTerminal(blockId string, msg string) {
log.Printf("error writing muted message to terminal (blockid=%s): %v", blockId, err)
}
}
+
+func writeJobTerminationMessage(ctx context.Context, jobId string, job *waveobj.Job, msg string) {
+ if job == nil {
+ return
+ }
+ shouldWrite := jobTerminationMessageWritten.TestAndSet(jobId, true, func(val bool, exists bool) bool {
+ return !exists || !val
+ })
+ if shouldWrite {
+ resetTerminalState(ctx, job.AttachedBlockId)
+ writeMutedMessageToTerminal(job.AttachedBlockId, msg)
+ }
+}
diff --git a/pkg/jobmanager/streammanager.go b/pkg/jobmanager/streammanager.go
index 43861449b7..8af2d64d2d 100644
--- a/pkg/jobmanager/streammanager.go
+++ b/pkg/jobmanager/streammanager.go
@@ -270,7 +270,6 @@ func (sm *StreamManager) readLoop() {
}
n, err := sm.reader.Read(readBuf)
- log.Printf("readLoop: read %d bytes from PTY, err=%v", n, err)
if n > 0 {
sm.handleReadData(readBuf[:n])
@@ -288,11 +287,9 @@ func (sm *StreamManager) readLoop() {
}
func (sm *StreamManager) handleReadData(data []byte) {
- log.Printf("handleReadData: writing %d bytes to buffer", len(data))
sm.buf.Write(data)
sm.lock.Lock()
defer sm.lock.Unlock()
- log.Printf("handleReadData: buffer size=%d, connected=%t, signaling=%t", sm.buf.Size(), sm.connected, sm.connected)
if sm.connected {
sm.drainCond.Signal()
}
@@ -336,25 +333,20 @@ func (sm *StreamManager) prepareNextPacket() (done bool, pkt *wshrpc.CommandStre
defer sm.lock.Unlock()
available := sm.buf.Size()
- log.Printf("prepareNextPacket: connected=%t, available=%d, closed=%t, terminalEventAcked=%t, terminalEvent=%v",
- sm.connected, available, sm.closed, sm.terminalEventAcked, sm.terminalEvent != nil)
if sm.closed || sm.terminalEventAcked {
return true, nil, nil
}
if !sm.connected {
- log.Printf("prepareNextPacket: waiting for connection")
sm.drainCond.Wait()
return false, nil, nil
}
if available == 0 {
if sm.terminalEvent != nil && !sm.terminalEventSent {
- log.Printf("prepareNextPacket: preparing terminal packet")
return false, sm.prepareTerminalPacket(), sm.dataSender
}
- log.Printf("prepareNextPacket: no data available, waiting")
sm.drainCond.Wait()
return false, nil, nil
}
@@ -381,7 +373,6 @@ func (sm *StreamManager) prepareNextPacket() (done bool, pkt *wshrpc.CommandStre
data := make([]byte, peekSize)
n := sm.buf.PeekDataAt(int(sm.sentNotAcked), data)
if n == 0 {
- log.Printf("prepareNextPacket: PeekDataAt returned 0 bytes, waiting for ACK")
sm.drainCond.Wait()
return false, nil, nil
}
@@ -390,7 +381,6 @@ func (sm *StreamManager) prepareNextPacket() (done bool, pkt *wshrpc.CommandStre
seq := sm.buf.HeadPos() + sm.sentNotAcked
sm.sentNotAcked += int64(n)
- log.Printf("prepareNextPacket: sending packet seq=%d, len=%d bytes", seq, n)
return false, &wshrpc.CommandStreamData{
Id: sm.streamId,
Seq: seq,
diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go
index d15fa70d76..d930dcf69d 100644
--- a/pkg/shellexec/shellexec.go
+++ b/pkg/shellexec/shellexec.go
@@ -339,6 +339,9 @@ func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize
if err != nil {
return nil, fmt.Errorf("unable to obtain client info: %w", err)
}
+ if remoteInfo.HomeDir == "" {
+ return nil, fmt.Errorf("unable to obtain home directory from remote machine")
+ }
log.Printf("client info collected: %+#v", remoteInfo)
var shellPath string
if cmdOpts.ShellPath != "" {
@@ -372,18 +375,18 @@ func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize
if shellType == shellutil.ShellType_bash {
// add --rcfile
// cant set -l or -i with --rcfile
- bashPath := fmt.Sprintf("~/.waveterm/%s/.bashrc", shellutil.BashIntegrationDir)
+ bashPath := fmt.Sprintf("%s/.waveterm/%s/.bashrc", remoteInfo.HomeDir, shellutil.BashIntegrationDir)
shellOpts = append(shellOpts, "--rcfile", bashPath)
} else if shellType == shellutil.ShellType_fish {
if cmdOpts.Login {
shellOpts = append(shellOpts, "-l")
}
// source the wave.fish file
- waveFishPath := fmt.Sprintf("~/.waveterm/%s/wave.fish", shellutil.FishIntegrationDir)
+ waveFishPath := fmt.Sprintf("%s/.waveterm/%s/wave.fish", remoteInfo.HomeDir, shellutil.FishIntegrationDir)
carg := fmt.Sprintf(`"source %s"`, waveFishPath)
shellOpts = append(shellOpts, "-C", carg)
} else if shellType == shellutil.ShellType_pwsh {
- pwshPath := fmt.Sprintf("~/.waveterm/%s/wavepwsh.ps1", shellutil.PwshIntegrationDir)
+ pwshPath := fmt.Sprintf("%s/.waveterm/%s/wavepwsh.ps1", remoteInfo.HomeDir, shellutil.PwshIntegrationDir)
// powershell is weird about quoted path executables and requires an ampersand first
shellPath = "& " + shellPath
shellOpts = append(shellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", pwshPath)
@@ -467,6 +470,9 @@ func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize w
if err != nil {
return "", fmt.Errorf("unable to obtain client info: %w", err)
}
+ if remoteInfo.HomeDir == "" {
+ return "", fmt.Errorf("unable to obtain home directory from remote machine")
+ }
log.Printf("client info collected: %+#v", remoteInfo)
var shellPath string
if cmdOpts.ShellPath != "" {
@@ -495,18 +501,17 @@ func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize w
if cmdStr == "" {
if shellType == shellutil.ShellType_bash {
- bashPath := fmt.Sprintf("~/.waveterm/%s/.bashrc", shellutil.BashIntegrationDir)
+ bashPath := fmt.Sprintf("%s/.waveterm/%s/.bashrc", remoteInfo.HomeDir, shellutil.BashIntegrationDir)
shellOpts = append(shellOpts, "--rcfile", bashPath)
} else if shellType == shellutil.ShellType_fish {
if cmdOpts.Login {
shellOpts = append(shellOpts, "-l")
}
- waveFishPath := fmt.Sprintf("~/.waveterm/%s/wave.fish", shellutil.FishIntegrationDir)
- carg := fmt.Sprintf(`"source %s"`, waveFishPath)
+ waveFishPath := fmt.Sprintf("%s/.waveterm/%s/wave.fish", remoteInfo.HomeDir, shellutil.FishIntegrationDir)
+ carg := fmt.Sprintf(`source %s`, waveFishPath)
shellOpts = append(shellOpts, "-C", carg)
} else if shellType == shellutil.ShellType_pwsh {
- pwshPath := fmt.Sprintf("~/.waveterm/%s/wavepwsh.ps1", shellutil.PwshIntegrationDir)
- shellPath = "& " + shellPath
+ pwshPath := fmt.Sprintf("%s/.waveterm/%s/wavepwsh.ps1", remoteInfo.HomeDir, shellutil.PwshIntegrationDir)
shellOpts = append(shellOpts, "-ExecutionPolicy", "Bypass", "-NoExit", "-File", pwshPath)
} else {
if cmdOpts.Login {
diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go
index 7f73bf02f9..c1fc17c76c 100644
--- a/pkg/telemetry/telemetrydata/telemetrydata.go
+++ b/pkg/telemetry/telemetrydata/telemetrydata.go
@@ -28,6 +28,7 @@ var ValidEventNames = map[string]bool{
"action:openwaveai": true,
"action:other": true,
"action:term": true,
+ "action:termdurable": true,
"wsh:run": true,
diff --git a/pkg/util/shellutil/shellutil.go b/pkg/util/shellutil/shellutil.go
index 6a78a67d53..3f8ee505be 100644
--- a/pkg/util/shellutil/shellutil.go
+++ b/pkg/util/shellutil/shellutil.go
@@ -623,11 +623,20 @@ func GetTerminalResetSeq() string {
resetSeq := "\x1b[0m" // reset attributes
resetSeq += "\x1b[?25h" // show cursor
resetSeq += "\x1b[?1l" // normal cursor keys
+ resetSeq += "\x1b[?6l" // origin mode off (DECOM)
resetSeq += "\x1b[?7h" // wraparound on
- resetSeq += "\x1b[?1000l" // disable mouse tracking
- resetSeq += "\x1b[?1007l" // disable alternate scroll mode
- resetSeq += "\x1b[?1004l" // disable focus reporting (FocusIn/FocusOut)
+ resetSeq += "\x1b[?45l" // reverse wraparound off
+ resetSeq += "\x1b[?66l" // application keypad off (DECNKM)
+ resetSeq += "\x1b[4l" // insert mode off (IRM)
+ resetSeq += "\x1b[?9l" // X10 mouse tracking off
+ resetSeq += "\x1b[?1000l" // disable Send Mouse X & Y on button press
+ resetSeq += "\x1b[?1002l" // disable Use Cell Motion Mouse Tracking
+ resetSeq += "\x1b[?1003l" // disable Use All Motion Mouse Tracking
+ resetSeq += "\x1b[?1004l" // disable Send FocusIn/FocusOut events
+ resetSeq += "\x1b[?1006l" // disable Enable SGR Mouse Mode
+ resetSeq += "\x1b[?1007l" // disable Enable Alternate Scroll Mode
resetSeq += "\x1b[?2004l" // disable bracketed paste mode
+ resetSeq += "\x1b[?2026l" // synchronized output off
resetSeq += FormatOSC(16162, "R") // disable alternate screen mode
return resetSeq
}
diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go
index 8df86d3766..0ac9e92eb1 100644
--- a/pkg/waveobj/wtype.go
+++ b/pkg/waveobj/wtype.go
@@ -322,6 +322,7 @@ type Job struct {
CmdEnv map[string]string `json:"cmdenv,omitempty"`
JobAuthToken string `json:"jobauthtoken"` // job manger -> wave
AttachedBlockId string `json:"attachedblockid,omitempty"`
+ WaveVersion string `json:"waveversion,omitempty"`
// reconnect option (e.g. orphaned, so we need to kill on connect)
TerminateOnReconnect bool `json:"terminateonreconnect,omitempty"`
diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json
index 9ccffde5a6..f3869e7007 100644
--- a/pkg/wconfig/defaultconfig/settings.json
+++ b/pkg/wconfig/defaultconfig/settings.json
@@ -25,7 +25,7 @@
"window:savelastwindow": true,
"telemetry:enabled": true,
"term:bellsound": false,
- "term:bellindicator": true,
+ "term:bellindicator": false,
"term:copyonselect": true,
"term:durable": false,
"waveai:showcloudmodes": true,
diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go
index f6ec49852b..00cad208f0 100644
--- a/pkg/wshrpc/wshrpctypes.go
+++ b/pkg/wshrpc/wshrpctypes.go
@@ -868,9 +868,10 @@ type TabIndicatorEventData struct {
type BlockJobStatusData struct {
BlockId string `json:"blockid"`
JobId string `json:"jobid"`
- Status string `json:"status" tstype:"null | \"init\" | \"connected\" | \"disconnected\" | \"done\""`
+ Status string `json:"status,omitempty" tstype:"null | \"init\" | \"connected\" | \"disconnected\" | \"done\""`
VersionTs int64 `json:"versionts"`
DoneReason string `json:"donereason,omitempty"`
+ StartupError string `json:"startuperror,omitempty"`
CmdExitTs int64 `json:"cmdexitts,omitempty"`
CmdExitCode *int `json:"cmdexitcode,omitempty"`
CmdExitSignal string `json:"cmdexitsignal,omitempty"`