Parcourir la source

feat: more intelligent reconnect method (#1045)

James Read il y a 2 semaines
Parent
commit
6b3896b1c6

+ 2 - 1
.gitignore

@@ -21,4 +21,5 @@ webui
 webui.dev
 sessions.yaml
 docs/build/
-build/
+build/
+.cursor

+ 149 - 21
frontend/js/websocket.js

@@ -2,52 +2,175 @@ import { buttonResults } from '../resources/vue/stores/buttonResults.js'
 import { rateLimits } from '../resources/vue/stores/rateLimits.js'
 import { connectionState } from '../resources/vue/stores/connectionState.js'
 
-const RECONNECT_DELAY_MS = 10000
+const RECONNECT_DELAYS_MS = [200, 1000, 2000, 4000, 8000, 16000, 32000]
+const BANNER_DELAY_MS = 2000
 
-export function initWebsocket () {
-  window.addEventListener('EventOutputChunk', onOutputChunk)
-  window.addEventListener('EventExecutionStarted', onExecutionChanged)
-  window.addEventListener('EventExecutionFinished', onExecutionChanged)
+let reconnectAttempt = 0
+let reconnectTimer = null
+let listenersInitialized = false
+let eventStreamGeneration = 0
+let eventStreamAbortController = null
+
+function shouldConnectEventStream () {
+  return window.initResponse && !window.initResponse.loginRequired
+}
+
+export function stopEventStream () {
+  eventStreamGeneration++
+  if (eventStreamAbortController != null) {
+    eventStreamAbortController.abort()
+    eventStreamAbortController = null
+  }
+
+  if (reconnectTimer != null) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+
+  reconnectAttempt = 0
+  connectionState.connected = false
+  connectionState.reconnecting = false
+  connectionState.scheduledReconnectDelayMs = 0
+  connectionState.nextReconnectAt = null
+  connectionState.showDisconnectedBanner = false
+  window.websocketAvailable = false
+}
+
+export function connectEventStreamIfNeeded () {
+  if (!shouldConnectEventStream()) {
+    stopEventStream()
+    return
+  }
+
+  if (connectionState.connected || reconnectTimer != null) {
+    return
+  }
 
   reconnectWebsocket()
 }
 
+export function initWebsocket () {
+  if (!listenersInitialized) {
+    window.addEventListener('EventOutputChunk', onOutputChunk)
+    window.addEventListener('EventExecutionStarted', onExecutionChanged)
+    window.addEventListener('EventExecutionFinished', onExecutionChanged)
+    window.addEventListener('pagehide', stopEventStream)
+    listenersInitialized = true
+  }
+
+  connectEventStreamIfNeeded()
+}
+
 window.websocketAvailable = false
 
+export function requestReconnectNow () {
+  if (!shouldConnectEventStream()) {
+    return
+  }
+
+  if (connectionState.connected) {
+    return
+  }
+
+  if (reconnectTimer != null) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+
+  reconnectAttempt = 0
+  scheduleReconnect(RECONNECT_DELAYS_MS[0])
+}
+
+function scheduleReconnect (delayMs) {
+  if (reconnectTimer != null) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+
+  connectionState.scheduledReconnectDelayMs = delayMs
+  connectionState.nextReconnectAt = delayMs > 0 ? Date.now() + delayMs : null
+  updateBannerVisibility()
+  reconnectTimer = setTimeout(() => {
+    reconnectTimer = null
+    reconnectWebsocket()
+  }, delayMs)
+}
+
+function updateBannerVisibility () {
+  if (connectionState.connected) {
+    connectionState.showDisconnectedBanner = false
+    return
+  }
+
+  connectionState.showDisconnectedBanner = connectionState.scheduledReconnectDelayMs >= BANNER_DELAY_MS
+}
+
 async function reconnectWebsocket () {
-  if (window.websocketAvailable) {
+  if (!shouldConnectEventStream()) {
+    return
+  }
+
+  if (connectionState.connected) {
     return
   }
 
+  const streamGeneration = ++eventStreamGeneration
+  if (eventStreamAbortController != null) {
+    eventStreamAbortController.abort()
+  }
+  eventStreamAbortController = new AbortController()
+
   connectionState.reconnecting = true
   connectionState.connected = false
   if (connectionState.disconnectedAt == null) {
     connectionState.disconnectedAt = Date.now()
   }
   connectionState.nextReconnectAt = null
+  connectionState.scheduledReconnectDelayMs = 0
 
   try {
     window.websocketAvailable = true
-    const stream = window.client.eventStream()
+    const stream = window.client.eventStream({}, { signal: eventStreamAbortController.signal })
     connectionState.connected = true
     connectionState.reconnecting = false
+    connectionState.disconnectedAt = null
     connectionState.nextReconnectAt = null
+    connectionState.scheduledReconnectDelayMs = 0
+    connectionState.showDisconnectedBanner = false
     for await (const e of stream) {
-      connectionState.disconnectedAt = null
+      if (streamGeneration !== eventStreamGeneration) {
+        return
+      }
+      if (reconnectAttempt !== 0) {
+        reconnectAttempt = 0
+      }
       handleEvent(e)
     }
   } catch (err) {
+    if (streamGeneration !== eventStreamGeneration) {
+      return
+    }
     console.error('Websocket connection failed: ', err)
   }
 
+  if (streamGeneration !== eventStreamGeneration) {
+    return
+  }
+
   window.websocketAvailable = false
   connectionState.connected = false
+  connectionState.reconnecting = false
   connectionState.disconnectedAt = connectionState.disconnectedAt ?? Date.now()
-  connectionState.nextReconnectAt = Date.now() + RECONNECT_DELAY_MS
-  console.log('Reconnecting websocket in ' + RECONNECT_DELAY_MS + 'ms...')
-  setTimeout(() => {
-    reconnectWebsocket()
-  }, RECONNECT_DELAY_MS)
+
+  const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)]
+  reconnectAttempt++
+  console.log('Reconnecting websocket in ' + delay + 'ms...')
+
+  if (!shouldConnectEventStream()) {
+    return
+  }
+
+  scheduleReconnect(delay)
 }
 
 async function refreshInitAfterConfigChange () {
@@ -83,6 +206,8 @@ function handleEvent (msg) {
         console.error('EventConfigChanged handler failed:', err)
       })
       break
+    case 'EventHeartbeat':
+      break
     case 'EventOutputChunk':
     case 'EventEntityChanged':
       window.dispatchEvent(j)
@@ -108,18 +233,21 @@ function onOutputChunk (evt) {
   }
 }
 
-function onExecutionChanged (evt) {
-  buttonResults[evt.payload.logEntry.executionTrackingId] = evt.payload.logEntry
+export function applyExecutionLogEntry (logEntry) {
+  if (!logEntry?.executionTrackingId) {
+    return
+  }
 
-  const logEntry = evt.payload.logEntry
+  buttonResults[logEntry.executionTrackingId] = logEntry
 
-  // Update rate limit store from logEntry if rate limit expiry datetime is provided
-  if (logEntry && logEntry.datetimeRateLimitExpires && logEntry.bindingId) {
-    // Parse datetime string "2006-01-02 15:04:05" and convert to Unix timestamp
+  if (logEntry.datetimeRateLimitExpires && logEntry.bindingId) {
     const date = new Date(logEntry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z')
     rateLimits[logEntry.bindingId] = date.getTime() / 1000
-  } else if (logEntry && logEntry.bindingId) {
-    // Clear rate limit if not set
+  } else if (logEntry.bindingId) {
     rateLimits[logEntry.bindingId] = 0
   }
 }
+
+function onExecutionChanged (evt) {
+  applyExecutionLogEntry(evt.payload.logEntry)
+}

+ 18 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -1135,6 +1135,12 @@ export declare type EventStreamResponse = Message<"olivetin.api.v1.EventStreamRe
      */
     value: EventOutputChunk;
     case: "outputChunk";
+  } | {
+    /**
+     * @generated from field: olivetin.api.v1.EventHeartbeat heartbeat = 7;
+     */
+    value: EventHeartbeat;
+    case: "heartbeat";
   } | { case: undefined; value?: undefined };
 };
 
@@ -1189,6 +1195,18 @@ export declare type EventConfigChanged = Message<"olivetin.api.v1.EventConfigCha
  */
 export declare const EventConfigChangedSchema: GenMessage<EventConfigChanged>;
 
+/**
+ * @generated from message olivetin.api.v1.EventHeartbeat
+ */
+export declare type EventHeartbeat = Message<"olivetin.api.v1.EventHeartbeat"> & {
+};
+
+/**
+ * Describes the message olivetin.api.v1.EventHeartbeat.
+ * Use `create(EventHeartbeatSchema)` to create a new message.
+ */
+export declare const EventHeartbeatSchema: GenMessage<EventHeartbeat>;
+
 /**
  * @generated from message olivetin.api.v1.EventExecutionFinished
  */
@@ -1919,4 +1937,3 @@ export declare const OliveTinApiService: GenService<{
     output: typeof EntitySchema;
   },
 }>;
-

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 36 - 1
frontend/resources/vue/ActionButton.vue

@@ -29,6 +29,8 @@
 <script setup>
 import { buttonResults } from './stores/buttonResults'
 import { rateLimits } from './stores/rateLimits'
+import { connectionState } from './stores/connectionState'
+import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
 import { useRouter } from 'vue-router'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
@@ -210,6 +212,28 @@ function getUniqueId() {
   }
 }
 
+async function pollExecutionUntilDone (trackingId) {
+  const pollIntervalMs = 500
+  const pollTimeoutMs = 10 * 60 * 1000
+  const deadline = Date.now() + pollTimeoutMs
+
+  while (Date.now() < deadline) {
+    try {
+      const result = await window.client.executionStatus({ executionTrackingId: trackingId })
+      if (result.logEntry) {
+        applyExecutionLogEntry(result.logEntry)
+        if (result.logEntry.executionFinished) {
+          return
+        }
+      }
+    } catch (err) {
+      console.error('Failed to poll execution status:', err)
+    }
+
+    await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
+  }
+}
+
 async function startAction(actionArgs) {
   buttonClasses.value = [] // Removes old animation classes
 
@@ -234,8 +258,19 @@ async function startAction(actionArgs) {
 	}
   )
 
+  requestReconnectNow()
+
   try {
-	await window.client.startAction(startActionArgs)
+	const response = await window.client.startAction(startActionArgs)
+	const trackingId = response.executionTrackingId || startActionArgs.uniqueTrackingId
+
+	if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
+	  router.push(`/logs/${trackingId}`)
+	}
+
+	if (!connectionState.connected) {
+	  await pollExecutionUntilDone(trackingId)
+	}
   } catch (err) {
 	console.error('Failed to start action:', err)
   }

+ 4 - 0
frontend/resources/vue/App.vue

@@ -100,6 +100,7 @@ import Sidebar from 'picocrank/vue/components/Sidebar.vue';
 import Navigation from 'picocrank/vue/components/Navigation.vue';
 import Header from 'picocrank/vue/components/Header.vue';
 import ConnectionBanner from './components/ConnectionBanner.vue';
+import { connectEventStreamIfNeeded } from '../../js/websocket.js';
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { Menu01Icon } from '@hugeicons/core-free-icons'
 import { UserCircle02Icon } from '@hugeicons/core-free-icons'
@@ -237,9 +238,12 @@ function updateHeaderFromInit() {
     applyTheme()
 
     if (window.initResponse.loginRequired) {
+        connectEventStreamIfNeeded()
         router.push('/login')
         return
     }
+
+    connectEventStreamIfNeeded()
 }
 
 function renderNavigation() {

+ 37 - 17
frontend/resources/vue/Dashboard.vue

@@ -30,11 +30,11 @@
             </div>
             <div class = "dashboard-row" v-for="component in dashboard.contents" :key="component.title">
                 <h2 v-if = "dashboard.title != 'Default'">
-                    <router-link 
-                        v-if="component.entityType && component.entityKey" 
-                        :to="{ 
-                            name: 'EntityDetails', 
-                            params: { 
+                    <router-link
+                        v-if="component.entityType && component.entityKey"
+                        :to="{
+                            name: 'EntityDetails',
+                            params: {
                                 entityType: component.entityType,
                                 entityKey: component.entityKey
                             }
@@ -57,7 +57,7 @@
 
 <script setup>
 import DashboardComponent from './components/DashboardComponent.vue'
-import { onMounted, onUnmounted, ref, computed } from 'vue'
+import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { Loading03Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
@@ -118,31 +118,31 @@ async function getDashboard() {
         const request = {
             title: title,
         }
-        
+
         if (props.entityType && props.entityKey) {
             request.entityType = props.entityType
             request.entityKey = props.entityKey
         }
-        
+
         const ret = await window.client.getDashboard(request)
 
         if (!ret || !ret.dashboard) {
             throw new Error('No dashboard found')
         }
 
-        dashboard.value = ret.dashboard 
+        dashboard.value = ret.dashboard
         const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
         document.title = ret.dashboard.title + ' - ' + pageTitle
-        
+
         // Clear any previous init error since we successfully loaded
         initError.value = null
-        
+
         // Stop the loading timer once dashboard is loaded
         if (loadingTimer) {
             clearInterval(loadingTimer)
             loadingTimer = null
         }
-        
+
         // Set attribute to indicate dashboard is loaded successfully
         document.body.setAttribute('loaded-dashboard', title || 'default')
     } catch (e) {
@@ -151,25 +151,35 @@ async function getDashboard() {
         dashboard.value = { title: title || 'Default', contents: [] }
         const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
         document.title = 'Error - ' + pageTitle
-        
+
         // Stop the loading timer on error
         if (loadingTimer) {
             clearInterval(loadingTimer)
             loadingTimer = null
         }
-        
+
         // Set attribute even on error so tests can proceed
         document.body.setAttribute('loaded-dashboard', title || 'error')
     }
 }
 
 function waitForInitAndLoadDashboard() {
-    // Start the loading timer
+    document.body.removeAttribute('loaded-dashboard')
+
+    if (loadingTimer) {
+        clearInterval(loadingTimer)
+        loadingTimer = null
+    }
+    if (checkInitInterval) {
+        clearInterval(checkInitInterval)
+        checkInitInterval = null
+    }
+
     loadingTime.value = 0
     loadingTimer = setInterval(() => {
         loadingTime.value++
     }, 1000)
-    
+
     // Check if init has completed successfully
     if (window.initResponse) {
         getDashboard()
@@ -206,7 +216,17 @@ onMounted(() => {
     waitForInitAndLoadDashboard()
 })
 
+watch(
+    () => [props.title, props.entityType, props.entityKey],
+    () => {
+        dashboard.value = null
+        waitForInitAndLoadDashboard()
+    }
+)
+
 onUnmounted(() => {
+    document.body.removeAttribute('loaded-dashboard')
+
     // Clean up the timers when component is unmounted
     if (loadingTimer) {
         clearInterval(loadingTimer)
@@ -295,4 +315,4 @@ fieldset {
         background-color: var(--bg-hover, #222);
     }
 }
-</style>
+</style>

+ 3 - 3
frontend/resources/vue/components/ConnectionBanner.vue

@@ -1,5 +1,5 @@
 <template>
-    <span id="connection-banner" v-if="!connectionState.connected" class="inline-notification critical user-info-connection">
+    <span id="connection-banner" v-if="connectionState.showDisconnectedBanner" class="inline-notification critical user-info-connection">
         <span class="connection-banner-sr-only" role="status">{{ staticAnnouncement }}</span>
         <span aria-hidden="true">
             <a :href="websocketDocsUrl" target="_blank" rel="noopener noreferrer" class="connection-banner-link">{{ linkText }}</a>{{ bannerSuffix }}
@@ -35,12 +35,12 @@ function formatShortTime(ts) {
 
 const now = ref(Date.now())
 let ticker = null
-watch(() => connectionState.connected, (connected) => {
+watch(() => connectionState.showDisconnectedBanner, (showBanner) => {
   if (ticker) {
     clearInterval(ticker)
     ticker = null
   }
-  if (!connected) {
+  if (showBanner) {
     now.value = Date.now()
     ticker = setInterval(() => { now.value = Date.now() }, 1000)
   }

+ 15 - 13
frontend/resources/vue/router.js

@@ -1,5 +1,7 @@
 import { createRouter, createWebHistory } from 'vue-router'
 
+import Dashboard from './Dashboard.vue'
+
 import { Wrench01Icon } from '@hugeicons/core-free-icons'
 import { LeftToRightListDashIcon } from '@hugeicons/core-free-icons'
 import { CellsIcon } from '@hugeicons/core-free-icons'
@@ -9,13 +11,13 @@ const routes = [
   {
     path: '/',
     name: 'Actions',
-    component: () => import('./Dashboard.vue'),
+    component: Dashboard,
     meta: { title: 'Actions', icon: DashboardSquare01Icon }
   },
   {
     path: '/dashboards/:title/:entityType?/:entityKey?',
     name: 'Dashboard',
-    component: () => import('./Dashboard.vue'),
+    component: Dashboard,
     props: true,
     meta: { title: 'Dashboard' }
   },
@@ -30,7 +32,7 @@ const routes = [
     path: '/logs',
     name: 'Logs',
     component: () => import('./views/LogsListView.vue'),
-    meta: { 
+    meta: {
       title: 'Logs',
       icon: LeftToRightListDashIcon
     }
@@ -39,7 +41,7 @@ const routes = [
     path: '/logs/calendar',
     name: 'LogsCalendar',
     component: () => import('./views/LogsCalendarView.vue'),
-    meta: { 
+    meta: {
       title: 'Logs Calendar',
       breadcrumb: [
         { name: "Logs", href: "/logs" },
@@ -51,7 +53,7 @@ const routes = [
     path: '/entities',
     name: 'Entities',
     component: () => import('./views/EntitiesView.vue'),
-    meta: { 
+    meta: {
       title: 'Entities',
       icon: CellsIcon
     }
@@ -61,8 +63,8 @@ const routes = [
     name: 'EntityDetails',
     component: () => import('./views/EntityDetailsView.vue'),
     props: true,
-    meta: { 
-      title: 'OliveTin - Entity Details', 
+    meta: {
+      title: 'OliveTin - Entity Details',
       breadcrumb: [
         { name: "Entities", href: "/entities" },
         { name: "Entity Details" }
@@ -74,8 +76,8 @@ const routes = [
     name: 'Execution',
     component: () => import('./views/ExecutionView.vue'),
     props: true,
-    meta: { 
-      title: 'Execution', 
+    meta: {
+      title: 'Execution',
       breadcrumb: [
         { name: "Logs", href: "/logs" },
         { name: "Execution" },
@@ -87,7 +89,7 @@ const routes = [
     name: 'ActionDetails',
     component: () => import('./views/ActionDetailsView.vue'),
     props: true,
-    meta: { 
+    meta: {
       title: 'Action Details',
       breadcrumb: [
         { name: "Actions", href: "/" },
@@ -112,7 +114,7 @@ const routes = [
     path: '/diagnostics',
     name: 'Diagnostics',
     component: () => import('./views/DiagnosticsView.vue'),
-    meta: { 
+    meta: {
       title: 'Diagnostics',
       icon: Wrench01Icon
     }
@@ -163,7 +165,7 @@ router.beforeEach((to, from, next) => {
 router.beforeEach((to, from, next) => {
   // Check if user is authenticated for protected routes
   const isAuthenticated = window.isAuthenticated || true // Default to true for now
-  
+
   if (to.meta.requiresAuth && !isAuthenticated) {
     next('/login')
   } else {
@@ -171,4 +173,4 @@ router.beforeEach((to, from, next) => {
   }
 })
 
-export default router 
+export default router

+ 3 - 1
frontend/resources/vue/stores/connectionState.js

@@ -4,5 +4,7 @@ export const connectionState = reactive({
   connected: false,
   reconnecting: false,
   disconnectedAt: null,
-  nextReconnectAt: null
+  nextReconnectAt: null,
+  scheduledReconnectDelayMs: 0,
+  showDisconnectedBanner: false
 })

+ 2 - 0
frontend/resources/vue/views/ActionDetailsView.vue

@@ -106,6 +106,7 @@ import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
+import { requestReconnectNow } from '../../../js/websocket.js'
 
 const route = useRoute()
 const router = useRouter()
@@ -290,6 +291,7 @@ async function startAction() {
   }
 
   try {
+    requestReconnectNow()
     const args = {
       "bindingId": action.value.bindingId,
       "arguments": []

+ 46 - 25
frontend/resources/vue/views/ArgumentForm.vue

@@ -45,7 +45,7 @@
         </div>
 
         <div class="buttons">
-          <button name="start" type="submit" :disabled="hasConfirmation && !confirmationChecked">
+          <button name="start" type="submit" :disabled="!formReady || (hasConfirmation && !confirmationChecked)">
             Start
           </button>
           <button name="cancel" type="button" @click="handleCancel">
@@ -58,8 +58,9 @@
 </template>
 
 <script setup>
-import { ref, onMounted, nextTick } from 'vue'
+import { ref, onMounted, onUnmounted, nextTick } from 'vue'
 import { useRouter } from 'vue-router'
+import { requestReconnectNow } from '../../../js/websocket.js'
 
 const router = useRouter()
 
@@ -74,6 +75,7 @@ const hasConfirmation = ref(false)
 const formErrors = ref({})
 const actionArguments = ref([])
 const popupOnStart = ref('')
+const formReady = ref(false)
 
 // Computed properties
 
@@ -86,23 +88,27 @@ const props = defineProps({
 
 // Methods
 async function setup() {
-  const ret = await window.client.getActionBinding({
-    bindingId: props.bindingId
-  })
-
-  const action = ret.action
-
-  title.value = action.title
-  icon.value = action.icon
-  popupOnStart.value = action.popupOnStart || ''
-  actionArguments.value = action.arguments || []
-  argValues.value = {}
-  formErrors.value = {}
-  confirmationChecked.value = false
-  hasConfirmation.value = false
-
-  // Initialize values from query params or defaults
-  actionArguments.value.forEach(arg => {
+  formReady.value = false
+  document.body.removeAttribute('loaded-argument-form')
+
+  try {
+    const ret = await window.client.getActionBinding({
+      bindingId: props.bindingId
+    })
+
+    const action = ret.action
+
+    title.value = action.title
+    icon.value = action.icon
+    popupOnStart.value = action.popupOnStart || ''
+    actionArguments.value = action.arguments || []
+    argValues.value = {}
+    formErrors.value = {}
+    confirmationChecked.value = false
+    hasConfirmation.value = false
+
+    // Initialize values from query params or defaults
+    actionArguments.value.forEach(arg => {
     if (arg.type === 'confirmation') {
       hasConfirmation.value = true
       const paramValue = getQueryParamValue(arg.name)
@@ -129,14 +135,20 @@ async function setup() {
         argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
       }
     }
-  })
+    })
 
-  // Run initial validation on all fields after DOM is updated
-  await nextTick()
-  for (const arg of actionArguments.value) {
-    if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '' && arg.type !== 'confirmation' && arg.type !== 'checkbox') {
-      await validateArgument(arg, argValues.value[arg.name] || '')
+    // Run initial validation on all fields after DOM is updated
+    await nextTick()
+    for (const arg of actionArguments.value) {
+      if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '' && arg.type !== 'confirmation' && arg.type !== 'checkbox') {
+        await validateArgument(arg, argValues.value[arg.name] || '')
+      }
     }
+
+    formReady.value = true
+    document.body.setAttribute('loaded-argument-form', props.bindingId)
+  } catch (err) {
+    console.error('Failed to load argument form:', err)
   }
 }
 
@@ -380,6 +392,7 @@ async function startAction(actionArgs) {
   }
 
   try {
+    requestReconnectNow()
     const response = await window.client.startAction(startActionArgs)
     console.log('Action started successfully with tracking ID:', response.executionTrackingId)
     return response
@@ -392,6 +405,10 @@ async function startAction(actionArgs) {
 async function handleSubmit(event) {
   event.preventDefault()
 
+  if (!formReady.value) {
+    return
+  }
+
   if (popupOnStart.value === 'history') {
     router.push(`/action/${props.bindingId}`)
     return
@@ -468,6 +485,10 @@ defineExpose({
 onMounted(() => {
   setup()
 })
+
+onUnmounted(() => {
+  document.body.removeAttribute('loaded-argument-form')
+})
 </script>
 
 <style scoped>

+ 2 - 0
frontend/resources/vue/views/ExecutionView.vue

@@ -70,6 +70,7 @@ import { HugeiconsIcon } from '@hugeicons/vue'
 import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
 import { useRouter } from 'vue-router'
 import { buttonResults } from '../stores/buttonResults'
+import { requestReconnectNow } from '../../../js/websocket.js'
 
 const router = useRouter()
 
@@ -177,6 +178,7 @@ async function rerunAction() {
   }
 
   try {
+    requestReconnectNow()
     const startActionArgs = {
       "bindingId": bindingId,
       "arguments": []

+ 53 - 12
integration-tests/lib/elements.js

@@ -3,6 +3,8 @@ import fs from 'fs'
 import { expect } from 'chai'
 import { Condition } from 'selenium-webdriver'
 
+export const DEFAULT_UI_WAIT_MS = 3000
+
 export async function getActionButtons () {
   // Currently, only the active dashboard's contents are rendered,
   // so we don't need to scope the selector by dashboard title.
@@ -10,11 +12,11 @@ export async function getActionButtons () {
 }
 
 export async function getExecutionDialogOutput() {
-    await webdriver.wait(new Condition('Dialog with long int is visible', async () => { 
+    await webdriver.wait(new Condition('Dialog with long int is visible', async () => {
       const dialog = await webdriver.findElement({ id: 'execution-results-popup' })
       return await dialog.isDisplayed()
     }));
-    
+
     const ret = await webdriver.executeScript('return window.logEntries.get(window.executionDialog.executionTrackingId).output')
 
     return ret
@@ -46,20 +48,59 @@ export function takeScreenshot (webdriver, title) {
   })
 }
 
-export async function getRootAndWait() {
-  await webdriver.get(runner.baseUrl())
-  await webdriver.wait(new Condition('wait for loaded-dashboard', async function() {
+export async function waitForDashboardLoaded(timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await webdriver.wait(new Condition('wait for loaded-dashboard', async function () {
     const body = await webdriver.findElement(By.tagName('body'))
     const attr = await body.getAttribute('loaded-dashboard')
 
     console.log('loaded-dashboard: ', attr)
 
-    if (attr) {
-      return true
-    } else {
+    return attr != null && attr !== ''
+  }), timeoutMs)
+}
+
+export async function waitForLogsPage(timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await webdriver.wait(new Condition('wait for logs page', async () => {
+    const url = await webdriver.getCurrentUrl()
+    return url.includes('/logs/') && !url.endsWith('/logs')
+  }), timeoutMs)
+}
+
+export async function waitForArgumentFormPage(timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await webdriver.wait(new Condition('wait for argument form page', async () => {
+    const url = await webdriver.getCurrentUrl()
+    return url.includes('/actionBinding/') && url.includes('/argumentForm')
+  }), timeoutMs)
+}
+
+export async function waitForArgumentFormReady(timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await webdriver.wait(new Condition('wait for argument form ready', async () => {
+    const body = await webdriver.findElement(By.tagName('body'))
+    const attr = await body.getAttribute('loaded-argument-form')
+    return attr != null && attr !== ''
+  }), timeoutMs)
+}
+
+export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await webdriver.wait(new Condition('wait for execution status', async () => {
+    const statusElements = await webdriver.findElements(By.id('execution-dialog-status'))
+    return statusElements.length > 0
+  }), timeoutMs)
+
+  await webdriver.wait(new Condition('wait for execution to finish', async () => {
+    try {
+      const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
+      const statusText = await statusElement.getText()
+      return !statusText.includes('Still running')
+    } catch (e) {
       return false
     }
-  }))
+  }), timeoutMs)
+}
+
+export async function getRootAndWait() {
+  await webdriver.get(runner.baseUrl())
+  await waitForDashboardLoaded()
 }
 
 export async function closeSidebar() {
@@ -82,7 +123,7 @@ export async function closeSidebar() {
       console.log('Sidebar closed, left is: *' + left, left === neededLeft ? ' (as expected)' : '')
       return left === neededLeft
     }
-  }), 10000); // Wait up to 10 seconds for the sidebar to close
+  }), DEFAULT_UI_WAIT_MS)
 }
 
 export async function openSidebar() {
@@ -103,7 +144,7 @@ export async function openSidebar() {
       console.log('Sidebar opened, left is: ', left)
       return true
     }
-  }));
+  }), DEFAULT_UI_WAIT_MS)
 }
 
 export async function getNavigationLinks() {
@@ -123,7 +164,7 @@ export async function requireExecutionDialogStatus (webdriver, expected) {
       console.log('Waiting for domStatus text to be: ', expected, ', it is currently: ', actual)
       return false
     }
-  }))
+  }), DEFAULT_UI_WAIT_MS)
 }
 
 export async function findExecutionDialog (webdriver) {

+ 18 - 4
integration-tests/runner.mjs

@@ -33,6 +33,10 @@ class OliveTinTestRunner {
 
 class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
   async start (cfg) {
+    if (this.ot != null && this.ot.exitCode == null) {
+      await this.stop()
+    }
+
     let stdout = ""
     let stderr = ""
 
@@ -94,13 +98,23 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
   }
 
   async stop () {
-    if ((await this.ot.exitCode) != null) {
-      console.log("      OliveTin local process tried stop(), but it already exited with code", this.ot.exitCode)
+    if (this.ot == null) {
+      return
+    }
+
+    if (this.ot.exitCode != null) {
+      console.log('      OliveTin local process tried stop(), but it already exited with code', this.ot.exitCode)
     } else {
-      await this.ot.kill()
-      console.log("      OliveTin local process killed")
+      const closed = new Promise((resolve) => {
+        this.ot.once('close', resolve)
+      })
+      this.ot.kill('SIGTERM')
+      await closed
+      console.log('      OliveTin local process killed')
     }
 
+    this.ot = null
+
     if (process.env.CI === 'true') {
       // GitHub runners seem to need a bit more time to clean up
       await new Promise((res) => setTimeout(res, 3000))

+ 8 - 50
integration-tests/tests/checkbox/checkbox.mjs

@@ -2,10 +2,14 @@ import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { By, Condition } from 'selenium-webdriver'
 import {
+  DEFAULT_UI_WAIT_MS,
   getRootAndWait,
   getActionButton,
   takeScreenshotOnFailure,
   getTerminalBuffer,
+  waitForArgumentFormPage,
+  waitForLogsPage,
+  waitForExecutionComplete,
 } from '../../lib/elements.js'
 
 async function openCheckboxArgumentForm() {
@@ -13,13 +17,7 @@ async function openCheckboxArgumentForm() {
   const btn = await getActionButton(webdriver, 'Test checkbox argument')
   await btn.click()
 
-  await webdriver.wait(
-    new Condition('wait for argument form page', async () => {
-      const url = await webdriver.getCurrentUrl()
-      return url.includes('/actionBinding/') && url.includes('/argumentForm')
-    }),
-    5000
-  )
+  await waitForArgumentFormPage()
 }
 
 async function getCheckboxInput() {
@@ -31,42 +29,6 @@ async function submitCheckboxForm() {
   await submitButton.click()
 }
 
-async function waitForLogsPage() {
-  await webdriver.wait(
-    new Condition('wait for logs page', async () => {
-      const url = await webdriver.getCurrentUrl()
-      return url.includes('/logs/') && !url.endsWith('/logs')
-    }),
-    5000
-  )
-}
-
-async function waitForExecutionComplete() {
-  await webdriver.wait(
-    new Condition('wait for execution status', async () => {
-      const statusElements = await webdriver.findElements(By.id('execution-dialog-status'))
-      return statusElements.length > 0
-    }),
-    5000
-  )
-
-  await webdriver.wait(
-    new Condition('wait for execution to finish', async () => {
-      try {
-        const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
-        const statusText = await statusElement.getText()
-        return !statusText.includes('Executing')
-      } catch (e) {
-        return false
-      }
-    }),
-    5000
-  )
-
-  // Small delay to allow terminal to write output
-  await webdriver.sleep(500)
-}
-
 async function waitForTerminalOutput(expectedValue) {
   await webdriver.wait(
     new Condition(`wait for checkbox value ${expectedValue} in output`, async () => {
@@ -77,18 +39,18 @@ async function waitForTerminalOutput(expectedValue) {
         if (!terminalReady) {
           return false
         }
-        
+
         const output = await getTerminalBuffer()
         if (!output) {
           return false
         }
-        
+
         return output.trim().includes(`Checkbox value: ${expectedValue}`)
       } catch (e) {
         return false
       }
     }),
-    5000
+    DEFAULT_UI_WAIT_MS
   )
 }
 
@@ -118,7 +80,6 @@ describe('config: checkbox', function () {
   })
 
   it('Checkbox argument submits 0 by default when unchecked', async function () {
-    this.timeout(15000)
     await openCheckboxArgumentForm()
 
     const checkboxInput = await getCheckboxInput()
@@ -131,7 +92,6 @@ describe('config: checkbox', function () {
   })
 
   it('Checkbox argument can be toggled and submitted', async function () {
-    this.timeout(15000)
     await openCheckboxArgumentForm()
 
     const checkboxInput = await getCheckboxInput()
@@ -146,5 +106,3 @@ describe('config: checkbox', function () {
     await waitForTerminalOutput('1')
   })
 })
-
-

+ 7 - 27
integration-tests/tests/datetime/datetime.mjs

@@ -1,10 +1,12 @@
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
-import { By, Condition } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 import {
   getRootAndWait,
   getActionButton,
   takeScreenshotOnFailure,
+  waitForArgumentFormPage,
+  waitForLogsPage,
 } from '../../lib/elements.js'
 
 describe('config: datetime', function () {
@@ -27,14 +29,7 @@ describe('config: datetime', function () {
 
     await btn.click()
 
-    // Wait for navigation to argument form page
-    await webdriver.wait(
-      new Condition('wait for argument form page', async () => {
-        const url = await webdriver.getCurrentUrl()
-        return url.includes('/actionBinding/') && url.includes('/argumentForm')
-      }),
-      8000
-    )
+    await waitForArgumentFormPage()
 
     // Find the datetime input field
     const datetimeInput = await webdriver.findElement(By.id('datetime'))
@@ -59,14 +54,7 @@ describe('config: datetime', function () {
 
     await btn.click()
 
-    // Wait for navigation to argument form page
-    await webdriver.wait(
-      new Condition('wait for argument form page', async () => {
-        const url = await webdriver.getCurrentUrl()
-        return url.includes('/actionBinding/') && url.includes('/argumentForm')
-      }),
-      8000
-    )
+    await waitForArgumentFormPage()
 
     // Find the datetime input field
     const datetimeInput = await webdriver.findElement(By.id('datetime'))
@@ -74,7 +62,7 @@ describe('config: datetime', function () {
     // Set a datetime value (format: YYYY-MM-DDTHH:mm)
     // datetime-local returns values without seconds, backend will add :00
     const testDateTime = '2023-12-25T15:30'
-    
+
     // Use JavaScript to set the value directly (more reliable for datetime-local inputs)
     await webdriver.executeScript(
       'arguments[0].value = arguments[1]',
@@ -101,18 +89,10 @@ describe('config: datetime', function () {
     )
     await submitButton.click()
 
-    // Wait for navigation to logs page
-    await webdriver.wait(
-      new Condition('wait for logs page', async () => {
-        const url = await webdriver.getCurrentUrl()
-        return url.includes('/logs/')
-      }),
-      8000
-    )
+    await waitForLogsPage()
 
     // Verify we're on the logs page (action was executed)
     const url = await webdriver.getCurrentUrl()
     expect(url).to.include('/logs/')
   })
 })
-

+ 8 - 8
integration-tests/tests/enabledExpression/enabledExpression.mjs

@@ -44,7 +44,7 @@ describe('config: enabledExpression', function () {
       }
       // Accept either decoded or encoded version (component should decode, but handle both)
       return attr === 'LightDashboard'
-    }), 10000)
+    }), 3000)
 
     // Verify we got the correct dashboard (prefer decoded, but accept encoded)
     const body = await webdriver.findElement(By.tagName('body'))
@@ -60,7 +60,7 @@ describe('config: enabledExpression', function () {
     // Debug: Check what's on the page
     const dashboardRows = await webdriver.findElements(By.css('.dashboard-row'))
     console.log(`Found ${dashboardRows.length} dashboard rows`)
-    
+
     for (let i = 0; i < dashboardRows.length; i++) {
       const row = dashboardRows[i]
       const h2Elements = await row.findElements(By.css('h2'))
@@ -82,25 +82,25 @@ describe('config: enabledExpression', function () {
     // Bedroom Light (powered_on: true) - Turn Off should be enabled, Turn On disabled
     let turnOnButton = null
     let turnOffButton = null
-    
+
     for (const row of dashboardRows) {
       // Get the fieldset in this row
       const fieldsets = await row.findElements(By.css('fieldset'))
       if (fieldsets.length === 0) continue
-      
+
       const buttons = await fieldsets[0].findElements(By.css('.action-button button'))
-      
+
       // Check each button to identify which entity this row represents
       for (const btn of buttons) {
         const title = await btn.getAttribute('title')
         const disabled = await btn.getAttribute('disabled')
         const isEnabled = disabled === null
-        
+
         if (title === 'Turn On Light' && isEnabled) {
           // This is the Living Room Light row (Turn On is enabled because powered_on: false)
           turnOnButton = btn
         }
-        
+
         if (title === 'Turn Off Light' && isEnabled) {
           // This is the Bedroom Light row (Turn Off is enabled because powered_on: true)
           turnOffButton = btn
@@ -127,7 +127,7 @@ describe('config: enabledExpression', function () {
     await webdriver.get(runner.baseUrl())
 
     // Wait for action buttons
-    await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000)
+    await webdriver.wait(until.elementLocated(By.css('.action-button')), 3000)
 
     // Find "Always Enabled Action" button
     const actionButtons = await webdriver.findElements(By.css('.action-button button'))

+ 4 - 4
integration-tests/tests/entities/entities.js

@@ -1,15 +1,15 @@
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
-import { By, until } from 'selenium-webdriver'
-import { 
-  getRootAndWait, 
-  takeScreenshot,
+import { By } from 'selenium-webdriver'
+import {
+  getRootAndWait,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 
 describe('config: entities', function () {
   before(async function () {
     await runner.start('entities')
+    await getRootAndWait()
   })
 
   after(async () => {

+ 7 - 14
integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js

@@ -1,16 +1,19 @@
 // Issue: https://github.com/OliveTin/OliveTin/issues/616
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 import {
   getRootAndWait,
   getActionButtons,
   takeScreenshotOnFailure,
+  waitForLogsPage,
+  waitForExecutionComplete,
 } from '../../lib/elements.js'
 
-describe('config: entities', function () {
+describe('config: entityFilesWithLongIntsUseStandardForm', function () {
   before(async function () {
     await runner.start('entityFilesWithLongIntsUseStandardForm')
+    await getRootAndWait()
   })
 
   after(async () => {
@@ -34,19 +37,9 @@ describe('config: entities', function () {
     expect(await buttonInt10.getAttribute('title')).to.be.equal('Test me INT with 10 numbers')
     await buttonInt10.click()
 
-    // Wait for navigation to execution view
-    await webdriver.wait(new Condition('wait for execution view', async () => {
-      const url = await webdriver.getCurrentUrl()
-      return url.includes('/logs/') && !url.endsWith('/logs')
-    }), 10000)
-
-    // Wait for execution to complete - look for the execution status
-    await webdriver.wait(new Condition('wait for execution status', async () => {
-      const statusElement = await webdriver.findElements(By.id('execution-dialog-status'))
-      return statusElement.length > 0
-    }), 15000)
+    await waitForLogsPage()
+    await waitForExecutionComplete()
 
-    // Check that the execution completed successfully by looking at the status
     const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
     const statusText = await statusElement.getText()
 

+ 42 - 79
integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs

@@ -2,23 +2,54 @@ import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { By, Condition } from 'selenium-webdriver'
 import {
+  DEFAULT_UI_WAIT_MS,
   getRootAndWait,
   getActionButton,
   takeScreenshotOnFailure,
+  waitForDashboardLoaded,
+  waitForLogsPage,
+  waitForArgumentFormPage,
+  waitForArgumentFormReady,
+  waitForExecutionComplete,
 } from '../../lib/elements.js'
 
-async function openArgumentForm() {
+async function ensureOnDashboard() {
+  let url = await webdriver.getCurrentUrl()
+
+  if (url.includes('/logs/')) {
+    const backButton = await webdriver.findElement(By.css('button[title="Go back"]'))
+    await backButton.click()
+    await webdriver.wait(
+      new Condition('wait for argument form after logs back', async () => {
+        const currentUrl = await webdriver.getCurrentUrl()
+        return currentUrl.includes('/argumentForm')
+      }),
+      DEFAULT_UI_WAIT_MS
+    )
+    url = await webdriver.getCurrentUrl()
+  }
+
+  if (url.includes('/argumentForm')) {
+    const cancelButton = await webdriver.findElement(By.css('button[name="cancel"]'))
+    await cancelButton.click()
+    await waitForDashboardLoaded()
+  }
+
+  const actionButtons = await webdriver.findElements(By.css('[title="Test suggestionsBrowserKey"]'))
+  if (actionButtons.length === 1) {
+    return
+  }
+
   await getRootAndWait()
+}
+
+async function openArgumentForm() {
+  await ensureOnDashboard()
   const btn = await getActionButton(webdriver, 'Test suggestionsBrowserKey')
   await btn.click()
 
-  await webdriver.wait(
-    new Condition('wait for argument form page', async () => {
-      const url = await webdriver.getCurrentUrl()
-      return url.includes('/actionBinding/') && url.includes('/argumentForm')
-    }),
-    5000
-  )
+  await waitForArgumentFormPage()
+  await waitForArgumentFormReady()
 }
 
 async function getTestInput() {
@@ -38,41 +69,6 @@ async function submitForm() {
   await submitButton.click()
 }
 
-async function waitForLogsPage() {
-  await webdriver.wait(
-    new Condition('wait for logs page', async () => {
-      const url = await webdriver.getCurrentUrl()
-      return url.includes('/logs/') && !url.endsWith('/logs')
-    }),
-    15000
-  )
-}
-
-async function waitForExecutionComplete() {
-  await webdriver.wait(
-    new Condition('wait for execution status', async () => {
-      const statusElements = await webdriver.findElements(By.id('execution-dialog-status'))
-      return statusElements.length > 0
-    }),
-    5000
-  )
-
-  await webdriver.wait(
-    new Condition('wait for execution to finish', async () => {
-      try {
-        const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
-        const statusText = await statusElement.getText()
-        return !statusText.includes('Executing')
-      } catch (e) {
-        return false
-      }
-    }),
-    5000
-  )
-
-  await webdriver.sleep(500)
-}
-
 async function getLocalStorageItem(key) {
   return await webdriver.executeScript(`return localStorage.getItem('${key}')`)
 }
@@ -84,6 +80,7 @@ async function clearLocalStorage() {
 describe('config: suggestionsBrowserKey', function () {
   before(async function () {
     await runner.start('suggestionsBrowserKey')
+    await getRootAndWait()
   })
 
   after(async () => {
@@ -113,11 +110,7 @@ describe('config: suggestionsBrowserKey', function () {
   })
 
   it('Submitting form saves value to localStorage', async function () {
-    this.timeout(15000)
-
-    // Clear localStorage first
     await clearLocalStorage()
-
     await openArgumentForm()
 
     const input = await getTestInput()
@@ -131,7 +124,6 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForExecutionComplete()
 
-    // Verify value was saved to localStorage
     const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
     expect(stored).to.not.be.null
 
@@ -141,26 +133,20 @@ describe('config: suggestionsBrowserKey', function () {
   })
 
   it('Previously saved values appear in datalist', async function () {
-    this.timeout(15000)
-
-    // First, save a value to localStorage
     const testValue = 'savedsuggestion456'
     await webdriver.executeScript(`
       const key = 'olivetin-suggestions-test-suggestions-key';
       localStorage.setItem(key, JSON.stringify(['${testValue}']));
     `)
 
-    // Open the form
     await openArgumentForm()
 
-    // Check that datalist exists and contains the saved value
     const datalist = await webdriver.findElement(By.id('testInput-choices'))
     expect(datalist).to.not.be.null
 
     const options = await getDatalistOptions()
     expect(options.length).to.be.greaterThan(0)
 
-    // Check if the saved value appears in the datalist
     let foundValue = false
     for (const option of options) {
       const value = await option.getAttribute('value')
@@ -173,12 +159,8 @@ describe('config: suggestionsBrowserKey', function () {
   })
 
   it('Multiple submissions accumulate suggestions', async function () {
-    this.timeout(20000)
-
-    // Clear localStorage first
     await clearLocalStorage()
 
-    // Submit first value
     await openArgumentForm()
     const input1 = await getTestInput()
     await input1.clear()
@@ -187,7 +169,6 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForExecutionComplete()
 
-    // Submit second value
     await openArgumentForm()
     const input2 = await getTestInput()
     await input2.clear()
@@ -196,7 +177,6 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForExecutionComplete()
 
-    // Verify both values are in localStorage
     const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
     expect(stored).to.not.be.null
 
@@ -204,43 +184,33 @@ describe('config: suggestionsBrowserKey', function () {
     expect(suggestions).to.be.an('array')
     expect(suggestions).to.include('firstvalue')
     expect(suggestions).to.include('secondvalue')
-    expect(suggestions[0]).to.equal('secondvalue') // Most recent should be first
+    expect(suggestions[0]).to.equal('secondvalue')
   })
 
   it('Empty values are not saved to localStorage', async function () {
-    this.timeout(15000)
-
-    // Clear localStorage first
     await clearLocalStorage()
-
     await openArgumentForm()
 
     const input = await getTestInput()
-    // Leave input empty (or clear it if it has a default)
     await input.clear()
 
     await submitForm()
     await waitForLogsPage()
     await waitForExecutionComplete()
 
-    // Verify empty value was not saved - localStorage should be null or empty-equivalent
     const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
-    // Should be null OR empty JSON array string ("[]") OR parse to empty array
     if (stored !== null) {
       const suggestions = JSON.parse(stored)
       expect(suggestions).to.be.an('array')
       expect(suggestions).to.have.length(0)
     }
-    // If stored is null, that's also acceptable - no assertion needed
   })
 
   it('Suggestions are shared across inputs with the same suggestionsBrowserKey', async function () {
-    this.timeout(20000)
+    this.timeout(12000)
 
-    // Clear localStorage first
     await clearLocalStorage()
 
-    // Submit a value using the first input
     await openArgumentForm()
     const input1 = await getTestInput()
     await input1.clear()
@@ -249,10 +219,8 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForExecutionComplete()
 
-    // Open the form again and verify the value appears in both datalists
     await openArgumentForm()
 
-    // Check first input's datalist
     const datalist1 = await webdriver.findElement(By.id('testInput-choices'))
     expect(datalist1).to.not.be.null
     const options1 = await getDatalistOptions('testInput')
@@ -266,7 +234,6 @@ describe('config: suggestionsBrowserKey', function () {
     }
     expect(foundInInput1).to.be.true
 
-    // Check second input's datalist
     const datalist2 = await webdriver.findElement(By.id('testInput2-choices'))
     expect(datalist2).to.not.be.null
     const options2 = await getDatalistOptions('testInput2')
@@ -280,7 +247,6 @@ describe('config: suggestionsBrowserKey', function () {
     }
     expect(foundInInput2).to.be.true
 
-    // Now submit a value using the second input
     const input2 = await getTestInput2()
     await input2.clear()
     await input2.sendKeys('sharedfrominput2')
@@ -288,10 +254,8 @@ describe('config: suggestionsBrowserKey', function () {
     await waitForLogsPage()
     await waitForExecutionComplete()
 
-    // Verify both values appear in both datalists
     await openArgumentForm()
 
-    // Check that both values are in the first input's datalist
     const options1After = await getDatalistOptions('testInput')
     let foundValue1 = false
     let foundValue2 = false
@@ -307,7 +271,6 @@ describe('config: suggestionsBrowserKey', function () {
     expect(foundValue1).to.be.true
     expect(foundValue2).to.be.true
 
-    // Check that both values are in the second input's datalist
     const options2After = await getDatalistOptions('testInput2')
     foundValue1 = false
     foundValue2 = false

+ 6 - 22
integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs

@@ -2,9 +2,12 @@ import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { By, Condition } from 'selenium-webdriver'
 import {
+  DEFAULT_UI_WAIT_MS,
   getRootAndWait,
   takeScreenshotOnFailure,
   getTerminalBuffer,
+  waitForLogsPage,
+  waitForExecutionComplete,
 } from '../../lib/elements.js'
 
 describe('config: xtermLinkHandling', function () {
@@ -26,32 +29,13 @@ describe('config: xtermLinkHandling', function () {
     await webdriver.wait(new Condition('wait for Echo URL button', async () => {
       const btns = await webdriver.findElements(By.css('[title="Echo URL"]'))
       return btns.length === 1
-    }), 10000)
+    }), DEFAULT_UI_WAIT_MS)
 
     const echoUrlButton = await webdriver.findElement(By.css('[title="Echo URL"]'))
     await echoUrlButton.click()
 
-    await webdriver.wait(new Condition('wait for execution view', async () => {
-      const url = await webdriver.getCurrentUrl()
-      return url.includes('/logs/') && !url.endsWith('/logs')
-    }), 10000)
-
-    await webdriver.wait(new Condition('wait for execution status', async () => {
-      const statusElements = await webdriver.findElements(By.id('execution-dialog-status'))
-      return statusElements.length > 0
-    }), 5000)
-
-    await webdriver.wait(new Condition('wait for execution to finish', async () => {
-      try {
-        const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
-        const statusText = await statusElement.getText()
-        return !statusText.includes('Executing')
-      } catch (e) {
-        return false
-      }
-    }), 5000)
-
-    await webdriver.sleep(500)
+    await waitForLogsPage()
+    await waitForExecutionComplete()
 
     const bufferText = await getTerminalBuffer()
     expect(bufferText).to.not.be.null

+ 2 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -261,6 +261,7 @@ message EventStreamResponse {
     EventExecutionFinished execution_finished = 4;
     EventExecutionStarted execution_started = 5;
     EventOutputChunk output_chunk = 6;
+    EventHeartbeat heartbeat = 7;
   }
 }
 
@@ -272,6 +273,7 @@ message EventOutputChunk {
 
 message EventEntityChanged {}
 message EventConfigChanged {}
+message EventHeartbeat {}
 message EventExecutionFinished {
 	LogEntry log_entry = 1;
 }

+ 232 - 175
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -2518,6 +2518,7 @@ type EventStreamResponse struct {
 	//	*EventStreamResponse_ExecutionFinished
 	//	*EventStreamResponse_ExecutionStarted
 	//	*EventStreamResponse_OutputChunk
+	//	*EventStreamResponse_Heartbeat
 	Event         isEventStreamResponse_Event `protobuf_oneof:"event"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
@@ -2605,6 +2606,15 @@ func (x *EventStreamResponse) GetOutputChunk() *EventOutputChunk {
 	return nil
 }
 
+func (x *EventStreamResponse) GetHeartbeat() *EventHeartbeat {
+	if x != nil {
+		if x, ok := x.Event.(*EventStreamResponse_Heartbeat); ok {
+			return x.Heartbeat
+		}
+	}
+	return nil
+}
+
 type isEventStreamResponse_Event interface {
 	isEventStreamResponse_Event()
 }
@@ -2629,6 +2639,10 @@ type EventStreamResponse_OutputChunk struct {
 	OutputChunk *EventOutputChunk `protobuf:"bytes,6,opt,name=output_chunk,json=outputChunk,proto3,oneof"`
 }
 
+type EventStreamResponse_Heartbeat struct {
+	Heartbeat *EventHeartbeat `protobuf:"bytes,7,opt,name=heartbeat,proto3,oneof"`
+}
+
 func (*EventStreamResponse_EntityChanged) isEventStreamResponse_Event() {}
 
 func (*EventStreamResponse_ConfigChanged) isEventStreamResponse_Event() {}
@@ -2639,6 +2653,8 @@ func (*EventStreamResponse_ExecutionStarted) isEventStreamResponse_Event() {}
 
 func (*EventStreamResponse_OutputChunk) isEventStreamResponse_Event() {}
 
+func (*EventStreamResponse_Heartbeat) isEventStreamResponse_Event() {}
+
 type EventOutputChunk struct {
 	state               protoimpl.MessageState `protogen:"open.v1"`
 	ExecutionTrackingId string                 `protobuf:"bytes,1,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"`
@@ -2763,6 +2779,42 @@ func (*EventConfigChanged) Descriptor() ([]byte, []int) {
 	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{45}
 }
 
+type EventHeartbeat struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *EventHeartbeat) Reset() {
+	*x = EventHeartbeat{}
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *EventHeartbeat) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*EventHeartbeat) ProtoMessage() {}
+
+func (x *EventHeartbeat) ProtoReflect() protoreflect.Message {
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use EventHeartbeat.ProtoReflect.Descriptor instead.
+func (*EventHeartbeat) Descriptor() ([]byte, []int) {
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{46}
+}
+
 type EventExecutionFinished struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	LogEntry      *LogEntry              `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"`
@@ -2772,7 +2824,7 @@ type EventExecutionFinished struct {
 
 func (x *EventExecutionFinished) Reset() {
 	*x = EventExecutionFinished{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2784,7 +2836,7 @@ func (x *EventExecutionFinished) String() string {
 func (*EventExecutionFinished) ProtoMessage() {}
 
 func (x *EventExecutionFinished) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2797,7 +2849,7 @@ func (x *EventExecutionFinished) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EventExecutionFinished.ProtoReflect.Descriptor instead.
 func (*EventExecutionFinished) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{46}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{47}
 }
 
 func (x *EventExecutionFinished) GetLogEntry() *LogEntry {
@@ -2816,7 +2868,7 @@ type EventExecutionStarted struct {
 
 func (x *EventExecutionStarted) Reset() {
 	*x = EventExecutionStarted{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2828,7 +2880,7 @@ func (x *EventExecutionStarted) String() string {
 func (*EventExecutionStarted) ProtoMessage() {}
 
 func (x *EventExecutionStarted) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2841,7 +2893,7 @@ func (x *EventExecutionStarted) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EventExecutionStarted.ProtoReflect.Descriptor instead.
 func (*EventExecutionStarted) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{47}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{48}
 }
 
 func (x *EventExecutionStarted) GetLogEntry() *LogEntry {
@@ -2860,7 +2912,7 @@ type KillActionRequest struct {
 
 func (x *KillActionRequest) Reset() {
 	*x = KillActionRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2872,7 +2924,7 @@ func (x *KillActionRequest) String() string {
 func (*KillActionRequest) ProtoMessage() {}
 
 func (x *KillActionRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2885,7 +2937,7 @@ func (x *KillActionRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use KillActionRequest.ProtoReflect.Descriptor instead.
 func (*KillActionRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{48}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{49}
 }
 
 func (x *KillActionRequest) GetExecutionTrackingId() string {
@@ -2907,7 +2959,7 @@ type KillActionResponse struct {
 
 func (x *KillActionResponse) Reset() {
 	*x = KillActionResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2919,7 +2971,7 @@ func (x *KillActionResponse) String() string {
 func (*KillActionResponse) ProtoMessage() {}
 
 func (x *KillActionResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2932,7 +2984,7 @@ func (x *KillActionResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use KillActionResponse.ProtoReflect.Descriptor instead.
 func (*KillActionResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{49}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{50}
 }
 
 func (x *KillActionResponse) GetExecutionTrackingId() string {
@@ -2973,7 +3025,7 @@ type LocalUserLoginRequest struct {
 
 func (x *LocalUserLoginRequest) Reset() {
 	*x = LocalUserLoginRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -2985,7 +3037,7 @@ func (x *LocalUserLoginRequest) String() string {
 func (*LocalUserLoginRequest) ProtoMessage() {}
 
 func (x *LocalUserLoginRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -2998,7 +3050,7 @@ func (x *LocalUserLoginRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use LocalUserLoginRequest.ProtoReflect.Descriptor instead.
 func (*LocalUserLoginRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{50}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{51}
 }
 
 func (x *LocalUserLoginRequest) GetUsername() string {
@@ -3024,7 +3076,7 @@ type LocalUserLoginResponse struct {
 
 func (x *LocalUserLoginResponse) Reset() {
 	*x = LocalUserLoginResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3036,7 +3088,7 @@ func (x *LocalUserLoginResponse) String() string {
 func (*LocalUserLoginResponse) ProtoMessage() {}
 
 func (x *LocalUserLoginResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3049,7 +3101,7 @@ func (x *LocalUserLoginResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use LocalUserLoginResponse.ProtoReflect.Descriptor instead.
 func (*LocalUserLoginResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{51}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{52}
 }
 
 func (x *LocalUserLoginResponse) GetSuccess() bool {
@@ -3068,7 +3120,7 @@ type PasswordHashRequest struct {
 
 func (x *PasswordHashRequest) Reset() {
 	*x = PasswordHashRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3080,7 +3132,7 @@ func (x *PasswordHashRequest) String() string {
 func (*PasswordHashRequest) ProtoMessage() {}
 
 func (x *PasswordHashRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3093,7 +3145,7 @@ func (x *PasswordHashRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use PasswordHashRequest.ProtoReflect.Descriptor instead.
 func (*PasswordHashRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{52}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{53}
 }
 
 func (x *PasswordHashRequest) GetPassword() string {
@@ -3112,7 +3164,7 @@ type PasswordHashResponse struct {
 
 func (x *PasswordHashResponse) Reset() {
 	*x = PasswordHashResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3124,7 +3176,7 @@ func (x *PasswordHashResponse) String() string {
 func (*PasswordHashResponse) ProtoMessage() {}
 
 func (x *PasswordHashResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3137,7 +3189,7 @@ func (x *PasswordHashResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use PasswordHashResponse.ProtoReflect.Descriptor instead.
 func (*PasswordHashResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{53}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{54}
 }
 
 func (x *PasswordHashResponse) GetHash() string {
@@ -3155,7 +3207,7 @@ type LogoutRequest struct {
 
 func (x *LogoutRequest) Reset() {
 	*x = LogoutRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3167,7 +3219,7 @@ func (x *LogoutRequest) String() string {
 func (*LogoutRequest) ProtoMessage() {}
 
 func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3180,7 +3232,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
 func (*LogoutRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{54}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{55}
 }
 
 type LogoutResponse struct {
@@ -3191,7 +3243,7 @@ type LogoutResponse struct {
 
 func (x *LogoutResponse) Reset() {
 	*x = LogoutResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3203,7 +3255,7 @@ func (x *LogoutResponse) String() string {
 func (*LogoutResponse) ProtoMessage() {}
 
 func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3216,7 +3268,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
 func (*LogoutResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{55}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{56}
 }
 
 type GetDiagnosticsRequest struct {
@@ -3227,7 +3279,7 @@ type GetDiagnosticsRequest struct {
 
 func (x *GetDiagnosticsRequest) Reset() {
 	*x = GetDiagnosticsRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3239,7 +3291,7 @@ func (x *GetDiagnosticsRequest) String() string {
 func (*GetDiagnosticsRequest) ProtoMessage() {}
 
 func (x *GetDiagnosticsRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3252,7 +3304,7 @@ func (x *GetDiagnosticsRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetDiagnosticsRequest.ProtoReflect.Descriptor instead.
 func (*GetDiagnosticsRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{56}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{57}
 }
 
 type GetDiagnosticsResponse struct {
@@ -3265,7 +3317,7 @@ type GetDiagnosticsResponse struct {
 
 func (x *GetDiagnosticsResponse) Reset() {
 	*x = GetDiagnosticsResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3277,7 +3329,7 @@ func (x *GetDiagnosticsResponse) String() string {
 func (*GetDiagnosticsResponse) ProtoMessage() {}
 
 func (x *GetDiagnosticsResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3290,7 +3342,7 @@ func (x *GetDiagnosticsResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetDiagnosticsResponse.ProtoReflect.Descriptor instead.
 func (*GetDiagnosticsResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{57}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{58}
 }
 
 func (x *GetDiagnosticsResponse) GetSshFoundKey() string {
@@ -3315,7 +3367,7 @@ type InitRequest struct {
 
 func (x *InitRequest) Reset() {
 	*x = InitRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3327,7 +3379,7 @@ func (x *InitRequest) String() string {
 func (*InitRequest) ProtoMessage() {}
 
 func (x *InitRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3340,7 +3392,7 @@ func (x *InitRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use InitRequest.ProtoReflect.Descriptor instead.
 func (*InitRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{58}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{59}
 }
 
 type InitResponse struct {
@@ -3376,7 +3428,7 @@ type InitResponse struct {
 
 func (x *InitResponse) Reset() {
 	*x = InitResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3388,7 +3440,7 @@ func (x *InitResponse) String() string {
 func (*InitResponse) ProtoMessage() {}
 
 func (x *InitResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3401,7 +3453,7 @@ func (x *InitResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use InitResponse.ProtoReflect.Descriptor instead.
 func (*InitResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{59}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{60}
 }
 
 func (x *InitResponse) GetShowFooter() bool {
@@ -3589,7 +3641,7 @@ type AdditionalLink struct {
 
 func (x *AdditionalLink) Reset() {
 	*x = AdditionalLink{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3601,7 +3653,7 @@ func (x *AdditionalLink) String() string {
 func (*AdditionalLink) ProtoMessage() {}
 
 func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3614,7 +3666,7 @@ func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead.
 func (*AdditionalLink) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{60}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{61}
 }
 
 func (x *AdditionalLink) GetTitle() string {
@@ -3642,7 +3694,7 @@ type OAuth2Provider struct {
 
 func (x *OAuth2Provider) Reset() {
 	*x = OAuth2Provider{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3654,7 +3706,7 @@ func (x *OAuth2Provider) String() string {
 func (*OAuth2Provider) ProtoMessage() {}
 
 func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3667,7 +3719,7 @@ func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead.
 func (*OAuth2Provider) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{61}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{62}
 }
 
 func (x *OAuth2Provider) GetTitle() string {
@@ -3700,7 +3752,7 @@ type GetActionBindingRequest struct {
 
 func (x *GetActionBindingRequest) Reset() {
 	*x = GetActionBindingRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3712,7 +3764,7 @@ func (x *GetActionBindingRequest) String() string {
 func (*GetActionBindingRequest) ProtoMessage() {}
 
 func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3725,7 +3777,7 @@ func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead.
 func (*GetActionBindingRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{62}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{63}
 }
 
 func (x *GetActionBindingRequest) GetBindingId() string {
@@ -3744,7 +3796,7 @@ type GetActionBindingResponse struct {
 
 func (x *GetActionBindingResponse) Reset() {
 	*x = GetActionBindingResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3756,7 +3808,7 @@ func (x *GetActionBindingResponse) String() string {
 func (*GetActionBindingResponse) ProtoMessage() {}
 
 func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3769,7 +3821,7 @@ func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead.
 func (*GetActionBindingResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{63}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{64}
 }
 
 func (x *GetActionBindingResponse) GetAction() *Action {
@@ -3787,7 +3839,7 @@ type GetEntitiesRequest struct {
 
 func (x *GetEntitiesRequest) Reset() {
 	*x = GetEntitiesRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3799,7 +3851,7 @@ func (x *GetEntitiesRequest) String() string {
 func (*GetEntitiesRequest) ProtoMessage() {}
 
 func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3812,7 +3864,7 @@ func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead.
 func (*GetEntitiesRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{64}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{65}
 }
 
 type GetEntitiesResponse struct {
@@ -3824,7 +3876,7 @@ type GetEntitiesResponse struct {
 
 func (x *GetEntitiesResponse) Reset() {
 	*x = GetEntitiesResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3836,7 +3888,7 @@ func (x *GetEntitiesResponse) String() string {
 func (*GetEntitiesResponse) ProtoMessage() {}
 
 func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3849,7 +3901,7 @@ func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead.
 func (*GetEntitiesResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{65}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{66}
 }
 
 func (x *GetEntitiesResponse) GetEntityDefinitions() []*EntityDefinition {
@@ -3870,7 +3922,7 @@ type EntityDefinition struct {
 
 func (x *EntityDefinition) Reset() {
 	*x = EntityDefinition{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3882,7 +3934,7 @@ func (x *EntityDefinition) String() string {
 func (*EntityDefinition) ProtoMessage() {}
 
 func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3895,7 +3947,7 @@ func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead.
 func (*EntityDefinition) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{66}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{67}
 }
 
 func (x *EntityDefinition) GetTitle() string {
@@ -3929,7 +3981,7 @@ type GetEntityRequest struct {
 
 func (x *GetEntityRequest) Reset() {
 	*x = GetEntityRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3941,7 +3993,7 @@ func (x *GetEntityRequest) String() string {
 func (*GetEntityRequest) ProtoMessage() {}
 
 func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3954,7 +4006,7 @@ func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead.
 func (*GetEntityRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{67}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{68}
 }
 
 func (x *GetEntityRequest) GetUniqueKey() string {
@@ -3980,7 +4032,7 @@ type RestartActionRequest struct {
 
 func (x *RestartActionRequest) Reset() {
 	*x = RestartActionRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -3992,7 +4044,7 @@ func (x *RestartActionRequest) String() string {
 func (*RestartActionRequest) ProtoMessage() {}
 
 func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4005,7 +4057,7 @@ func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead.
 func (*RestartActionRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{68}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69}
 }
 
 func (x *RestartActionRequest) GetExecutionTrackingId() string {
@@ -4220,19 +4272,21 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x10GetReadyzRequest\"+\n" +
 	"\x11GetReadyzResponse\x12\x16\n" +
 	"\x06status\x18\x01 \x01(\tR\x06status\"\x14\n" +
-	"\x12EventStreamRequest\"\xb3\x03\n" +
+	"\x12EventStreamRequest\"\xf4\x03\n" +
 	"\x13EventStreamResponse\x12L\n" +
 	"\x0eentity_changed\x18\x02 \x01(\v2#.olivetin.api.v1.EventEntityChangedH\x00R\rentityChanged\x12L\n" +
 	"\x0econfig_changed\x18\x03 \x01(\v2#.olivetin.api.v1.EventConfigChangedH\x00R\rconfigChanged\x12X\n" +
 	"\x12execution_finished\x18\x04 \x01(\v2'.olivetin.api.v1.EventExecutionFinishedH\x00R\x11executionFinished\x12U\n" +
 	"\x11execution_started\x18\x05 \x01(\v2&.olivetin.api.v1.EventExecutionStartedH\x00R\x10executionStarted\x12F\n" +
-	"\foutput_chunk\x18\x06 \x01(\v2!.olivetin.api.v1.EventOutputChunkH\x00R\voutputChunkB\a\n" +
+	"\foutput_chunk\x18\x06 \x01(\v2!.olivetin.api.v1.EventOutputChunkH\x00R\voutputChunk\x12?\n" +
+	"\theartbeat\x18\a \x01(\v2\x1f.olivetin.api.v1.EventHeartbeatH\x00R\theartbeatB\a\n" +
 	"\x05event\"^\n" +
 	"\x10EventOutputChunk\x122\n" +
 	"\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId\x12\x16\n" +
 	"\x06output\x18\x02 \x01(\tR\x06output\"\x14\n" +
 	"\x12EventEntityChanged\"\x14\n" +
-	"\x12EventConfigChanged\"P\n" +
+	"\x12EventConfigChanged\"\x10\n" +
+	"\x0eEventHeartbeat\"P\n" +
 	"\x16EventExecutionFinished\x126\n" +
 	"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"O\n" +
 	"\x15EventExecutionStarted\x126\n" +
@@ -4355,7 +4409,7 @@ func file_olivetin_api_v1_olivetin_proto_rawDescGZIP() []byte {
 	return file_olivetin_api_v1_olivetin_proto_rawDescData
 }
 
-var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 75)
+var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 76)
 var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*Action)(nil),                          // 0: olivetin.api.v1.Action
 	(*ActionWebhookExecHint)(nil),           // 1: olivetin.api.v1.ActionWebhookExecHint
@@ -4403,44 +4457,45 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*EventOutputChunk)(nil),                // 43: olivetin.api.v1.EventOutputChunk
 	(*EventEntityChanged)(nil),              // 44: olivetin.api.v1.EventEntityChanged
 	(*EventConfigChanged)(nil),              // 45: olivetin.api.v1.EventConfigChanged
-	(*EventExecutionFinished)(nil),          // 46: olivetin.api.v1.EventExecutionFinished
-	(*EventExecutionStarted)(nil),           // 47: olivetin.api.v1.EventExecutionStarted
-	(*KillActionRequest)(nil),               // 48: olivetin.api.v1.KillActionRequest
-	(*KillActionResponse)(nil),              // 49: olivetin.api.v1.KillActionResponse
-	(*LocalUserLoginRequest)(nil),           // 50: olivetin.api.v1.LocalUserLoginRequest
-	(*LocalUserLoginResponse)(nil),          // 51: olivetin.api.v1.LocalUserLoginResponse
-	(*PasswordHashRequest)(nil),             // 52: olivetin.api.v1.PasswordHashRequest
-	(*PasswordHashResponse)(nil),            // 53: olivetin.api.v1.PasswordHashResponse
-	(*LogoutRequest)(nil),                   // 54: olivetin.api.v1.LogoutRequest
-	(*LogoutResponse)(nil),                  // 55: olivetin.api.v1.LogoutResponse
-	(*GetDiagnosticsRequest)(nil),           // 56: olivetin.api.v1.GetDiagnosticsRequest
-	(*GetDiagnosticsResponse)(nil),          // 57: olivetin.api.v1.GetDiagnosticsResponse
-	(*InitRequest)(nil),                     // 58: olivetin.api.v1.InitRequest
-	(*InitResponse)(nil),                    // 59: olivetin.api.v1.InitResponse
-	(*AdditionalLink)(nil),                  // 60: olivetin.api.v1.AdditionalLink
-	(*OAuth2Provider)(nil),                  // 61: olivetin.api.v1.OAuth2Provider
-	(*GetActionBindingRequest)(nil),         // 62: olivetin.api.v1.GetActionBindingRequest
-	(*GetActionBindingResponse)(nil),        // 63: olivetin.api.v1.GetActionBindingResponse
-	(*GetEntitiesRequest)(nil),              // 64: olivetin.api.v1.GetEntitiesRequest
-	(*GetEntitiesResponse)(nil),             // 65: olivetin.api.v1.GetEntitiesResponse
-	(*EntityDefinition)(nil),                // 66: olivetin.api.v1.EntityDefinition
-	(*GetEntityRequest)(nil),                // 67: olivetin.api.v1.GetEntityRequest
-	(*RestartActionRequest)(nil),            // 68: olivetin.api.v1.RestartActionRequest
-	nil,                                     // 69: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
-	nil,                                     // 70: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
-	nil,                                     // 71: olivetin.api.v1.ActionArgument.SuggestionsEntry
-	nil,                                     // 72: olivetin.api.v1.Entity.FieldsEntry
-	nil,                                     // 73: olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	nil,                                     // 74: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	(*EventHeartbeat)(nil),                  // 46: olivetin.api.v1.EventHeartbeat
+	(*EventExecutionFinished)(nil),          // 47: olivetin.api.v1.EventExecutionFinished
+	(*EventExecutionStarted)(nil),           // 48: olivetin.api.v1.EventExecutionStarted
+	(*KillActionRequest)(nil),               // 49: olivetin.api.v1.KillActionRequest
+	(*KillActionResponse)(nil),              // 50: olivetin.api.v1.KillActionResponse
+	(*LocalUserLoginRequest)(nil),           // 51: olivetin.api.v1.LocalUserLoginRequest
+	(*LocalUserLoginResponse)(nil),          // 52: olivetin.api.v1.LocalUserLoginResponse
+	(*PasswordHashRequest)(nil),             // 53: olivetin.api.v1.PasswordHashRequest
+	(*PasswordHashResponse)(nil),            // 54: olivetin.api.v1.PasswordHashResponse
+	(*LogoutRequest)(nil),                   // 55: olivetin.api.v1.LogoutRequest
+	(*LogoutResponse)(nil),                  // 56: olivetin.api.v1.LogoutResponse
+	(*GetDiagnosticsRequest)(nil),           // 57: olivetin.api.v1.GetDiagnosticsRequest
+	(*GetDiagnosticsResponse)(nil),          // 58: olivetin.api.v1.GetDiagnosticsResponse
+	(*InitRequest)(nil),                     // 59: olivetin.api.v1.InitRequest
+	(*InitResponse)(nil),                    // 60: olivetin.api.v1.InitResponse
+	(*AdditionalLink)(nil),                  // 61: olivetin.api.v1.AdditionalLink
+	(*OAuth2Provider)(nil),                  // 62: olivetin.api.v1.OAuth2Provider
+	(*GetActionBindingRequest)(nil),         // 63: olivetin.api.v1.GetActionBindingRequest
+	(*GetActionBindingResponse)(nil),        // 64: olivetin.api.v1.GetActionBindingResponse
+	(*GetEntitiesRequest)(nil),              // 65: olivetin.api.v1.GetEntitiesRequest
+	(*GetEntitiesResponse)(nil),             // 66: olivetin.api.v1.GetEntitiesResponse
+	(*EntityDefinition)(nil),                // 67: olivetin.api.v1.EntityDefinition
+	(*GetEntityRequest)(nil),                // 68: olivetin.api.v1.GetEntityRequest
+	(*RestartActionRequest)(nil),            // 69: olivetin.api.v1.RestartActionRequest
+	nil,                                     // 70: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	nil,                                     // 71: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	nil,                                     // 72: olivetin.api.v1.ActionArgument.SuggestionsEntry
+	nil,                                     // 73: olivetin.api.v1.Entity.FieldsEntry
+	nil,                                     // 74: olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	nil,                                     // 75: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
 }
 var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	2,  // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument
 	1,  // 1: olivetin.api.v1.Action.exec_on_webhooks:type_name -> olivetin.api.v1.ActionWebhookExecHint
-	69, // 2: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
-	70, // 3: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	70, // 2: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	71, // 3: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
 	3,  // 4: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice
-	71, // 5: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
-	72, // 6: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
+	72, // 5: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
+	73, // 6: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
 	8,  // 7: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard
 	9,  // 8: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent
 	9,  // 9: olivetin.api.v1.DashboardComponent.contents:type_name -> olivetin.api.v1.DashboardComponent
@@ -4452,77 +4507,78 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	20, // 15: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
 	20, // 16: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
 	20, // 17: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
-	73, // 18: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	74, // 19: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	74, // 18: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	75, // 19: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
 	44, // 20: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
 	45, // 21: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
-	46, // 22: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
-	47, // 23: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
+	47, // 22: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
+	48, // 23: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
 	43, // 24: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk
-	20, // 25: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
-	20, // 26: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
-	61, // 27: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
-	60, // 28: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
-	6,  // 29: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
-	0,  // 30: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
-	66, // 31: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
-	4,  // 32: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
-	36, // 33: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
-	7,  // 34: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
-	10, // 35: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
-	13, // 36: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
-	15, // 37: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
-	17, // 38: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
-	68, // 39: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
-	48, // 40: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
-	28, // 41: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
-	19, // 42: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
-	22, // 43: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
-	24, // 44: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
-	30, // 45: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
-	32, // 46: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
-	34, // 47: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
-	37, // 48: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
-	39, // 49: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
-	50, // 50: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
-	52, // 51: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
-	54, // 52: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
-	41, // 53: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
-	56, // 54: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
-	58, // 55: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
-	62, // 56: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
-	64, // 57: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
-	67, // 58: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
-	5,  // 59: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
-	12, // 60: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
-	14, // 61: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
-	16, // 62: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
-	18, // 63: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
-	12, // 64: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
-	49, // 65: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
-	29, // 66: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
-	21, // 67: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
-	23, // 68: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
-	25, // 69: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
-	31, // 70: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
-	33, // 71: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
-	35, // 72: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
-	38, // 73: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
-	40, // 74: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
-	51, // 75: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
-	53, // 76: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
-	55, // 77: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
-	42, // 78: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
-	57, // 79: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
-	59, // 80: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
-	63, // 81: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
-	65, // 82: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
-	4,  // 83: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
-	59, // [59:84] is the sub-list for method output_type
-	34, // [34:59] is the sub-list for method input_type
-	34, // [34:34] is the sub-list for extension type_name
-	34, // [34:34] is the sub-list for extension extendee
-	0,  // [0:34] is the sub-list for field type_name
+	46, // 25: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat
+	20, // 26: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
+	20, // 27: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
+	62, // 28: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
+	61, // 29: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
+	6,  // 30: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
+	0,  // 31: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
+	67, // 32: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
+	4,  // 33: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
+	36, // 34: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
+	7,  // 35: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
+	10, // 36: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
+	13, // 37: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
+	15, // 38: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
+	17, // 39: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
+	69, // 40: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
+	49, // 41: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
+	28, // 42: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
+	19, // 43: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
+	22, // 44: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
+	24, // 45: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
+	30, // 46: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
+	32, // 47: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
+	34, // 48: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
+	37, // 49: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
+	39, // 50: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
+	51, // 51: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
+	53, // 52: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
+	55, // 53: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
+	41, // 54: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
+	57, // 55: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
+	59, // 56: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
+	63, // 57: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
+	65, // 58: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
+	68, // 59: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
+	5,  // 60: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
+	12, // 61: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
+	14, // 62: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
+	16, // 63: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
+	18, // 64: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
+	12, // 65: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
+	50, // 66: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
+	29, // 67: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
+	21, // 68: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
+	23, // 69: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
+	25, // 70: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
+	31, // 71: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
+	33, // 72: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
+	35, // 73: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
+	38, // 74: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
+	40, // 75: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
+	52, // 76: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
+	54, // 77: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
+	56, // 78: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
+	42, // 79: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
+	58, // 80: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
+	60, // 81: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
+	64, // 82: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
+	66, // 83: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
+	4,  // 84: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
+	60, // [60:85] is the sub-list for method output_type
+	35, // [35:60] is the sub-list for method input_type
+	35, // [35:35] is the sub-list for extension type_name
+	35, // [35:35] is the sub-list for extension extendee
+	0,  // [0:35] is the sub-list for field type_name
 }
 
 func init() { file_olivetin_api_v1_olivetin_proto_init() }
@@ -4536,6 +4592,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
 		(*EventStreamResponse_ExecutionFinished)(nil),
 		(*EventStreamResponse_ExecutionStarted)(nil),
 		(*EventStreamResponse_OutputChunk)(nil),
+		(*EventStreamResponse_Heartbeat)(nil),
 	}
 	type x struct{}
 	out := protoimpl.TypeBuilder{
@@ -4543,7 +4600,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)),
 			NumEnums:      0,
-			NumMessages:   75,
+			NumMessages:   76,
 			NumExtensions: 0,
 			NumServices:   1,
 		},

+ 81 - 8
service/internal/api/api.go

@@ -58,18 +58,43 @@ func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
 type streamingClient struct {
 	channel           chan *apiv1.EventStreamResponse
 	AuthenticatedUser *authpublic.AuthenticatedUser
+	heartbeatStopOnce sync.Once
+	heartbeatStop     chan struct{}
+	heartbeatDone     chan struct{}
 }
 
-// trySendEventToClient sends msg to the client's channel. Returns false if the channel is full (client should be removed).
+func (c *streamingClient) stopHeartbeat() {
+	if c.heartbeatStop == nil || c.heartbeatDone == nil {
+		return
+	}
+	c.heartbeatStopOnce.Do(func() {
+		close(c.heartbeatStop)
+	})
+	<-c.heartbeatDone
+}
+
+// trySendEventToClient sends msg to the client's channel. Returns false if the channel is full or closed.
 func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool {
 	if client == nil || msg == nil {
 		return false
 	}
+	sent := sendToStreamingClientChannel(client.channel, msg)
+	if !sent {
+		log.Warnf("EventStream: client channel is full or closed, removing client")
+	}
+	return sent
+}
+
+func sendToStreamingClientChannel(ch chan *apiv1.EventStreamResponse, msg *apiv1.EventStreamResponse) (sent bool) {
+	defer func() {
+		if recover() != nil {
+			sent = false
+		}
+	}()
 	select {
-	case client.channel <- msg:
+	case ch <- msg:
 		return true
 	default:
-		log.Warnf("EventStream: client channel is full, removing client")
 		return false
 	}
 }
@@ -933,6 +958,8 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 	client := &streamingClient{
 		channel:           make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
 		AuthenticatedUser: user,
+		heartbeatStop:     make(chan struct{}),
+		heartbeatDone:     make(chan struct{}),
 	}
 
 	log.WithFields(log.Fields{
@@ -943,6 +970,8 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 	api.streamingClients[client] = struct{}{}
 	api.streamingClientsMutex.Unlock()
 
+	go api.sendEventStreamHeartbeats(client)
+
 	// loop over client channel and send events to connectedClient
 	for msg := range client.channel {
 		log.Debugf("Sending event to client: %v", msg)
@@ -959,13 +988,59 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 	return nil
 }
 
+func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) {
+	defer close(client.heartbeatDone)
+
+	if !api.sendEventStreamHeartbeat(client) {
+		return
+	}
+
+	ticker := time.NewTicker(10 * time.Second)
+	defer ticker.Stop()
+	api.runEventStreamHeartbeatLoop(client, ticker)
+}
+
+func (api *oliveTinAPI) runEventStreamHeartbeatLoop(client *streamingClient, ticker *time.Ticker) {
+	for {
+		if api.waitEventStreamHeartbeatOrDone(client.heartbeatStop, ticker) {
+			return
+		}
+		if !api.sendEventStreamHeartbeat(client) {
+			return
+		}
+	}
+}
+
+func (api *oliveTinAPI) waitEventStreamHeartbeatOrDone(done <-chan struct{}, ticker *time.Ticker) bool {
+	select {
+	case <-done:
+		return true
+	case <-ticker.C:
+		return false
+	}
+}
+
+func (api *oliveTinAPI) sendEventStreamHeartbeat(client *streamingClient) bool {
+	msg := &apiv1.EventStreamResponse{
+		Event: &apiv1.EventStreamResponse_Heartbeat{
+			Heartbeat: &apiv1.EventHeartbeat{},
+		},
+	}
+	return api.trySendEventToClient(client, msg)
+}
+
 func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
 	if clientToRemove == nil {
 		return
 	}
 	api.streamingClientsMutex.Lock()
+	if _, exists := api.streamingClients[clientToRemove]; !exists {
+		api.streamingClientsMutex.Unlock()
+		return
+	}
 	delete(api.streamingClients, clientToRemove)
 	api.streamingClientsMutex.Unlock()
+	clientToRemove.stopHeartbeat()
 	close(clientToRemove.channel)
 }
 
@@ -973,14 +1048,12 @@ func (api *oliveTinAPI) OnActionMapRebuilt() {
 	toRemove := []*streamingClient{}
 
 	for _, client := range api.copyOfStreamingClients() {
-		select {
-		case client.channel <- &apiv1.EventStreamResponse{
+		msg := &apiv1.EventStreamResponse{
 			Event: &apiv1.EventStreamResponse_ConfigChanged{
 				ConfigChanged: &apiv1.EventConfigChanged{},
 			},
-		}:
-		default:
-			log.Warnf("EventStream: client channel is full, removing client")
+		}
+		if !api.trySendEventToClient(client, msg) {
 			toRemove = append(toRemove, client)
 		}
 	}

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff