diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go index ea46a8cc6e..9f63976115 100644 --- a/cmd/wsh/cmd/wshcmd-connserver.go +++ b/cmd/wsh/cmd/wshcmd-connserver.go @@ -375,7 +375,8 @@ func serverRun(cmd *cobra.Command, args []string) error { var logFile *os.File if connServerDev { var err error - logFile, err = os.OpenFile("/tmp/connserver.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + logFilePath := fmt.Sprintf("/tmp/waveterm-connserver-%d.log", os.Getuid()) + logFile, err = os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err) log.SetFlags(log.LstdFlags | log.Lmicroseconds) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index c07544f11a..aad9e7d8c6 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -66,7 +66,7 @@ wsh editconfig | term:shiftenternewline | bool | when enabled, Shift+Enter sends escape sequence + newline (\u001b\n) instead of carriage return, useful for claude code and similar AI coding tools (default false) | | term:macoptionismeta | bool | on macOS, treat the Option key as Meta key for terminal keybindings (default false) | | term:bellsound | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) | -| term:bellindicator | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default true) | +| term:bellindicator | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false) | | term:durable | bool | makes remote terminal sessions durable across network disconnects (defaults to true) | | editor:minimapenabled | bool | set to false to disable editor minimap | | editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | @@ -133,7 +133,7 @@ For reference, this is the current default configuration (v0.11.5): "window:savelastwindow": true, "telemetry:enabled": true, "term:bellsound": false, - "term:bellindicator": true, + "term:bellindicator": false, "term:copyonselect": true, "term:durable": true, "waveai:showcloudmodes": true, diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 5a96b34918..352fd6fac2 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -9,6 +9,7 @@ import { renderHeaderElements, } from "@/app/block/blockutil"; import { ConnectionButton } from "@/app/block/connectionbutton"; +import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { getConnStatusAtom, recordTEvent, WOS } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; @@ -23,52 +24,6 @@ import * as jotai from "jotai"; import * as React from "react"; import { BlockFrameProps } from "./blocktypes"; -function getDurableIconProps(jobStatus: BlockJobStatusData, connStatus: ConnStatus, isConfigedDurable?: boolean | null) { - let color = "text-muted"; - let titleText = "Durable Session"; - let iconType: "fa-solid" | "fa-regular" = "fa-solid"; - - if (isConfigedDurable === false) { - color = "text-muted"; - titleText = "Standard Session"; - iconType = "fa-regular"; - return { color, titleText, iconType }; - } - - const status = jobStatus?.status; - if (status === "connected") { - color = "text-sky-500"; - titleText = "Durable Session (Attached)"; - } else if (status === "disconnected") { - color = "text-sky-300"; - titleText = "Durable Session (Detached)"; - } else if (status === "init") { - color = "text-sky-300"; - titleText = "Durable Session (Starting)"; - } else if (status === "done") { - color = "text-muted"; - const doneReason = jobStatus?.donereason; - if (doneReason === "terminated") { - titleText = "Durable Session (Ended, Exited)"; - } else if (doneReason === "gone") { - titleText = "Durable Session (Ended, Environment Lost)"; - } else if (doneReason === "startuperror") { - titleText = "Durable Session (Ended, Failed to Start)"; - } else { - titleText = "Durable Session (Ended)"; - } - } else if (status == null) { - if (!connStatus?.connected) { - color = "text-muted"; - titleText = "Durable Session (Awaiting Connection)"; - } else { - color = "text-muted"; - titleText = "No Session"; - } - } - return { color, titleText, iconType }; -} - function handleHeaderContextMenu( e: React.MouseEvent, 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 ( + + ); +} + +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. +
+ + +
+ ); +} + +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. +
+ + +
+ ); +} + +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} +
+ )} + + + +
+ ); + } + + return ( +
+
+ + {titleText} +
+
{descriptionText}
+ {showRestartButton && ( + + )} + +
+ ); +} + +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"`