UNPKG

@flowfuse/device-agent

Version:

An Edge Agent for running Node-RED instances deployed from the FlowFuse Platform

874 lines (825 loc) 44.5 kB
const { IntervalJitter } = require('./IntervalJitter') const { existsSync } = require('fs') const { randomInt } = require('crypto') const fs = require('fs/promises') const { readFileSync } = require('fs') const path = require('path') const httpClient = require('./http') const mqttClient = require('./mqtt') const semver = require('semver') const Launcher = require('./launcher.js') const { info, warn, debug } = require('./logging/log') const utils = require('./utils.js') const { States, isTargetState, isValidState } = require('./states') const MQTT_CONNECT_DELAY_MAX = process.env.NODE_ENV === 'test' ? 25 : 5000 const PROJECT_FILE = 'flowforge-project.json' class Agent { constructor (config) { this.config = config this.startTime = Date.now() this.projectFilePath = path.join(this.config.dir, PROJECT_FILE) /** @type {import('./AgentManager').AgentManager} */ this.AgentManager = null /** @type {import('./http.js').HTTPClient} */ this.httpClient = httpClient.newHTTPClient(this, this.config) /** @type {import('./launcher.js').Launcher} */ this.launcher = null /** @type {import('./mqtt').MQTTClient} */ this.mqttClient = null this.currentSnapshot = null this.currentSettings = null this.currentProject = null this.currentApplication = null this.currentMode = 'autonomous' this.targetState = config.targetState || States.RUNNING this.updating = false this.queuedUpdate = null /** @type {IntervalJitter} a timer for scheduling retries of `setState()` */ this.retrySetStateTimer = new IntervalJitter() // Track the local state of the agent. Start in 'unknown' state so // that the first MQTT check-in will trigger a response this.currentState = States.UNKNOWN this.editorToken = null this.editorAffinity = null // ensure licensed property is present (default to null) if (utils.hasProperty(this.config, 'licensed') === false) { this.config.licensed = null } } async loadProject () { if (existsSync(this.projectFilePath)) { try { const config = JSON.parse(await fs.readFile(this.projectFilePath, 'utf8')) if (config.id) { // Old format this.currentSnapshot = config if (this.currentSnapshot.device) { this.currentSettings = this.currentSnapshot.device delete this.currentSnapshot.device } this.currentProject = null this.currentApplication = null this.editorToken = null this.editorAffinity = null } else { // New format this.currentApplication = config.project ? null : (config.application || null) this.currentProject = config.project || null this.currentSnapshot = config.snapshot || null this.currentSettings = config.settings || null this.currentMode = config.mode || 'autonomous' this.targetState = isTargetState(config.targetState) ? config.targetState : States.RUNNING this.config.licensed = config.licensed || null this.editorToken = config.editorToken || null this.editorAffinity = config.editorAffinity || null } this.printAgentStatus() } catch (err) { warn(`Invalid project file: ${this.projectFilePath}`) } } } printAgentStatus (title = null) { if (title) { info(title) } info('Configuration :-') if (this.currentOwnerType === 'application') { info(` * Application : ${this.currentApplication}`) info(' * Snapshot : none') // TODO: remove this when we have a better solution for snapshots on devices at application level } else { info(` * Instance : ${this.currentProject || 'unknown'}`) info(` * Snapshot : ${this.currentSnapshot?.id || 'none'}`) } info(` * Settings : ${this.currentSettings?.hash || 'none'}`) info(` * Operation Mode : ${this.currentMode || 'unknown'}`) info(` * Target State : ${this.targetState || States.RUNNING}`) info(` * Local Login : ${this.currentSettings?.security?.localAuth?.enabled ? 'enabled' : 'disabled'}`) info(` * Licensed : ${this.config.licensed === null ? 'unknown' : this.config.licensed ? 'yes' : 'no'}`) if (typeof this.currentSettings?.env === 'object') { info('Environment :-') info(` * FF_DEVICE_ID : ${this.currentSettings.env.FF_DEVICE_ID || ''}`) info(` * FF_DEVICE_NAME : ${this.currentSettings.env.FF_DEVICE_NAME || ''}`) info(` * FF_DEVICE_TYPE : ${this.currentSettings.env.FF_DEVICE_TYPE || ''}`) if (this.currentOwnerType === 'application') { info(` * FF_APPLICATION_ID : ${this.currentSettings.env.FF_APPLICATION_ID || ''}`) info(` * FF_APPLICATION_NAME: ${this.currentSettings.env.FF_APPLICATION_NAME || ''}`) } info(` * FF_SNAPSHOT_ID : ${this.currentSettings.env.FF_SNAPSHOT_ID || ''}`) info(` * FF_SNAPSHOT_NAME : ${this.currentSettings.env.FF_SNAPSHOT_NAME || ''}`) } } async saveProject () { await fs.writeFile(this.projectFilePath, JSON.stringify({ ownerType: this.currentOwnerType, application: this.currentApplication, project: this.currentProject, snapshot: this.currentSnapshot, settings: this.currentSettings, mode: this.currentMode, targetState: this.targetState, licensed: this.config.licensed, editorToken: this.editorToken, editorAffinity: this.editorAffinity })) } async start () { if (this.config?.provisioningMode) { this.currentState = States.PROVISIONING await this.httpClient.startPolling() } else { await this.loadProject() if (this.config?.brokerURL) { // ensure http comms are stopped if using MQTT this.httpClient.stopPolling() // ensure any existing MQTT comms are stopped before initiating new ones if (this.mqttClient) { this.mqttClient.stop() } // We have been provided a broker URL to use this.mqttClient = mqttClient.newMQTTClient(this, this.config) // Wait a short random delay to reduce stress on broker when large numbers of devices come on-line await new Promise(_resolve => setTimeout(_resolve, randomInt(20, MQTT_CONNECT_DELAY_MAX))) this.mqttClient.start() this.mqttClient.setApplication(this.currentApplication) this.mqttClient.setProject(this.currentProject) } else { // ensure MQTT comms are stopped if switching to HTTP if (this.mqttClient && this.config?.brokerURL) { this.mqttClient.stop() } this.currentState = States.STOPPED // Fallback to HTTP polling await this.httpClient.startPolling() } } return this.currentState } async stop () { // Stop the launcher before stopping http/mqtt channels to permit // audit logging and status updates to the platform if (this.launcher) { // Stop the launcher using non std state 'shutdown' to indicate a shutdown. // This is mainly for consistent logging and preventing the auto restart // logic kicking in when the agent is stopped await this.launcher.stop(false, 'shutdown') this.launcher = undefined } await this.httpClient.stopPolling() if (this.mqttClient) { this.mqttClient.stop() } } async restartNR () { this.currentState = States.RESTARTING this.retrySetState(false) // clear any retry timers if (this.launcher) { // Stop the launcher using the state 'restarting' // This will not be persisted to the targetState property // It indicates the launcher it should not attempt to auto restart // the NR process but permit the process to exit gracefully await this.launcher.stop(false, States.RESTARTING) this.launcher = undefined } await this.updateTargetState(States.RUNNING) await this.setState({ targetState: States.RUNNING, reloadSettings: true }) return this.targetState === States.RUNNING } async startNR () { await this.updateTargetState(States.RUNNING) await this.setState({ targetState: States.RUNNING }) return this.targetState === States.RUNNING } async suspendNR () { this.retrySetState(false) // clear any retry timers // update the settings to indicate the device is suspended so that upon // a reboot the device agent will not start the launcher const result = await this.updateTargetState(States.SUSPENDED) if (this.launcher) { await this.launcher.stop(false, States.SUSPENDED) this.launcher = undefined this.currentState = States.SUSPENDED } return result && this.targetState === States.SUSPENDED } async updateTargetState (newState) { if (isTargetState(newState)) { const changed = this.targetState !== newState this.targetState = newState if (changed) { this.retrySetState(false) // clear any retry timers this.targetState = newState await this.saveProject() } return true } return false } async getCurrentPackage () { if (this.launcher) { return this.launcher.readPackage() } return null } async getCurrentFlows () { if (this.launcher) { return await this.launcher.readFlow() } return null } async getCurrentCredentials () { if (this.launcher) { return await this.launcher.readCredentials() } return null } getState () { if (this.updating) { return null } const state = { ownerType: this.currentOwnerType, project: this.currentProject || null, application: this.currentApplication || null, snapshot: this.currentSnapshot?.id || null, settings: this.currentSettings?.hash || null, state: this.launcher?.state || this.currentState, mode: this.currentMode, targetState: this.targetState, health: { uptime: Math.floor((Date.now() - this.startTime) / 1000), snapshotRestartCount: this.launcher?.restartCount || 0 }, agentVersion: this.config.version, licensed: this.config.licensed } if (this.launcher?.readPackage) { const { modules } = this.launcher.readPackage() if (semver.valid(modules['node-red']) !== null) { state.nodeRedVersion = modules['node-red'] } else { try { const nrPackPath = path.join(this.launcher.projectDir, 'node_modules/node-red/package.json') const content = readFileSync(nrPackPath) const packJSON = JSON.parse(content) state.nodeRedVersion = packJSON.version } catch (err) { // Bad node-red install } } } if (this.currentMode === 'developer' && this.editorToken && this.editorAffinity) { state.affinity = this.editorAffinity } return state } /** * @type {('none'|'application'|'project')} * Returns the current owner type of the agent */ get currentOwnerType () { return this.currentProject ? 'project' : (this.currentApplication ? 'application' : 'none') } static normaliseStateObject (newState) { // if the new state is null or not an object, set it to null // this is necessary since explicit "no state" check is a comparison to `null` if (newState === null || typeof newState !== 'object') { return null } // for backwards compatibility, check if the new state object has a property named "ownerType" // if not, try to determine it from properties. NOTE: Project takes precedence over application. // This permits us to migrate to a future where a device have an application and a project value at the same time // This aligns with the getter "currentOwnerType" which also gives precedence to project over application if (utils.hasProperty(newState, 'ownerType') === false || newState.ownerType === null) { newState.ownerType = newState.project ? 'project' : (newState.application ? 'application' : 'none') } } async setState (newState) { debug(JSON.stringify(newState)) // If busy updating, queue the update if (this.updating) { const queuedUpdateIsTargetStateChange = this.queuedUpdate && typeof this.queuedUpdate === 'object' && utils.hasProperty(this.queuedUpdate, 'targetState') if (queuedUpdateIsTargetStateChange) { // the queued update is a target state change request, lets not overwrite it // unless the new state is also a target state change request const newStateIsTargetStateChange = typeof newState === 'object' && utils.hasProperty(newState, 'targetState') if (newStateIsTargetStateChange) { this.queuedUpdate = newState } return } this.queuedUpdate = newState return } try { this.updating = true // normalise the state object Agent.normaliseStateObject(newState) // store license status - this property can be used for enabling EE features if (newState && utils.hasProperty(newState, 'licensed') && typeof newState.licensed === 'boolean') { const licenseChanged = newState.licensed !== this.config.licensed if (licenseChanged) { this.config.licensed = newState.licensed this.saveProject() // update project file if (this.config.licensed) { info('License enabled') // TODO: handle license change disabled -> enabled. Flag for reload? } else { info('License disabled') // TODO: handle license change enabled -> disabled. Flag for reload? } } } /** `forgeSnapshot` will be set to the current snapshot in the forge platform *if required* */ let forgeSnapshot = null // check to see if this is run state change request if (typeof newState === 'object' && utils.hasProperty(newState, 'targetState')) { if (isTargetState(newState.targetState)) { const changed = newState.targetState !== this.targetState this.targetState = newState.targetState await this.saveProject() if (changed) { this.retrySetState(false) // since this is a target state change, cancel any retry timers } } delete newState.targetState } // next, check if the new state indicates a change of operation mode from the current mode // When changing from developer mode to autonomous mode, we need to check if the flows/modules // for Node-RED were changed vs the current snapshot on the forge platform. // If they differ, we flag that a reload of the snapshot is required. if (newState !== null && newState.mode && newState.mode !== this.currentMode) { if (!['developer', 'autonomous'].includes(newState.mode)) { newState.mode = 'autonomous' } if (!this.currentMode) { this.currentMode = newState.mode } else if (this.currentMode !== newState.mode) { this.currentMode = newState.mode if (newState.mode === 'developer') { info('Enabling developer mode') await this.saveProject() } else { // exiting developer mode this.editorToken = null this.editorAffinity = null let _launcher = this.launcher if (!_launcher) { // create a temporary launcher to read the current snapshot on disk _launcher = Launcher.newLauncher(this, this.currentApplication, this.currentProject, this.currentSnapshot, this.currentSettings, this.currentMode) } try { forgeSnapshot = await this.httpClient.getSnapshot() this.retrySetState(false) // success - stop retry timer } catch (err) { if (!this.retrySetStateTimer.isRunning) { this.currentState = States.ERROR warn(`Problem getting snapshot: ${err.toString()}`) debug(err) this.retrySetState(newState) } this.updating = false this.currentState = States.ERROR this.queuedUpdate = null // we are in error state, clear any queued updates, halt! return } // before checking for changed flows etc, check if the snapshot on disk is the same as the snapshot on the forge platform // if it has changed, we need to reload the snapshot from the forge platform if (forgeSnapshot?.id !== _launcher.snapshot?.id) { info('Local snapshot ID differs from the snapshot on the forge platform') newState.reloadSnapshot = true } // next check the key system environment variables match if (newState.reloadSnapshot !== true) { const checkMatch = (key) => { return (forgeSnapshot?.env[key] || null) === (_launcher?.snapshot?.env[key] || null) } if (newState.ownerType === 'application') { // TODO: Since this is an early MVP of devices at application level, we fake any updates to the snapshot // We DONT reload the snapshot from the forge platform because we don't want to overwrite any local // changes made to flows and modules. This is a temporary workaround until we have a better solution if (typeof forgeSnapshot?.env === 'object' && typeof _launcher.snapshot?.env === 'object') { const matchOk = checkMatch('FF_SNAPSHOT_ID') && checkMatch('FF_SNAPSHOT_NAME') && checkMatch('FF_DEVICE_ID') && checkMatch('FF_DEVICE_NAME') && checkMatch('FF_DEVICE_TYPE') && checkMatch('FF_APPLICATION_ID') && checkMatch('FF_APPLICATION_NAME') if (matchOk === false) { info('Local environment variables differ from the snapshot on the forge platform') // manually update the snapshot to match the snapshot from the forge platform // this is a temporary workaround until we have a better solution for devices at application level this.currentSnapshot.env.FF_SNAPSHOT_ID = forgeSnapshot.env.FF_SNAPSHOT_ID this.currentSnapshot.env.FF_SNAPSHOT_NAME = forgeSnapshot.env.FF_SNAPSHOT_NAME this.currentSnapshot.env.FF_DEVICE_ID = forgeSnapshot.env.FF_DEVICE_ID this.currentSnapshot.env.FF_DEVICE_NAME = forgeSnapshot.env.FF_DEVICE_NAME this.currentSnapshot.env.FF_DEVICE_TYPE = forgeSnapshot.env.FF_DEVICE_TYPE this.currentSnapshot.env.FF_APPLICATION_ID = forgeSnapshot.env.FF_APPLICATION_ID this.currentSnapshot.env.FF_APPLICATION_NAME = forgeSnapshot.env.FF_APPLICATION_NAME } } } else { if (typeof forgeSnapshot?.env === 'object' && typeof _launcher.snapshot?.env === 'object') { const matchOk = checkMatch('FF_SNAPSHOT_ID') && checkMatch('FF_SNAPSHOT_NAME') && checkMatch('FF_DEVICE_ID') && checkMatch('FF_DEVICE_NAME') && checkMatch('FF_DEVICE_TYPE') if (matchOk === false) { info('Local environment variables differ from the snapshot on the forge platform') newState.reloadSnapshot = true } } } } // Do a full comparison if this is NOT an application with a "starter" snapshot ID of "0" const doFull = !(newState.ownerType === 'application' && newState.snapshot === '0') if (doFull && newState.reloadSnapshot !== true) { let diskSnapshot = { flows: [], modules: {} } try { const modules = (_launcher.readPackage())?.modules const flows = await _launcher.readFlow() diskSnapshot = { flows, modules } } catch (error) { info('An error occurred while attempting to read flows & package file from disk') newState.reloadSnapshot = true } const changes = utils.compareNodeRedData(forgeSnapshot, diskSnapshot) === false if (changes) { info('Local flows differ from the snapshot on the forge platform') newState.reloadSnapshot = true } } if (newState.reloadSnapshot) { info('Local flows have changed. Restoring current snapshot') } else { // only save the project if the snapshot is not being reloaded // since the snapshot will be reloaded and the project will be saved then await this.saveProject() } info('Disabling developer mode') } // report the new mode for more instantaneous feedback (improve the UX) this.checkIn(2) } } /** A flag to inhibit updates if we are in developer mode */ const developerMode = this.currentMode === 'developer' /** A flag to indicate execution should skip to the update step */ const skipToUpdate = newState?.reloadSnapshot === true if (newState === null) { // The agent should not be running (bad credentials/device details) // Wipe the local configuration if (developerMode === false) { await this.stop() this.currentSnapshot = null this.currentApplication = null this.currentProject = null this.currentSettings = null this.currentMode = null this.editorToken = null this.editorAffinity = null await this.saveProject() this.currentState = States.STOPPED this.updating = false } } else if (!skipToUpdate && developerMode === false && newState.application === null && this.currentOwnerType === 'application') { if (this.currentApplication) { debug('Removed from application') } // Device unassigned from application if (this.mqttClient) { this.mqttClient.setApplication(null) } // Stop the device if running - with clean flag if (this.launcher) { await this.launcher.stop(true) this.launcher = undefined } this.currentApplication = null this.currentSnapshot = null // if new settings hash is explicitly null, clear the current settings // otherwise, if currentSettings.hash exists, see if it differs from the // new settings hash & update accordingly if (newState.settings === null) { this.currentSettings = null } else if (this.currentSettings?.hash) { if (this.currentSettings.hash !== newState.settings) { this.currentSettings = await this.httpClient.getSettings() } } await this.saveProject() this.currentState = States.STOPPED this.updating = false } else if (!skipToUpdate && developerMode === false && newState.project === null && this.currentOwnerType === 'project') { if (this.currentProject) { debug('Removed from project') } // Device unassigned from project if (this.mqttClient) { this.mqttClient.setProject(null) } // Stop the project if running - with clean flag if (this.launcher) { await this.launcher.stop(true) this.launcher = undefined } this.currentProject = null this.currentSnapshot = null // if new settings hash is explicitly null, clear the current settings // otherwise, if currentSettings.hash exists, see if it differs from the // new settings hash & update accordingly if (newState.settings === null) { this.currentSettings = null } else if (this.currentSettings?.hash) { if (this.currentSettings.hash !== newState.settings) { this.currentSettings = await this.httpClient.getSettings() } } await this.saveProject() this.currentState = States.STOPPED this.updating = false } else if (!skipToUpdate && developerMode === false && newState.snapshot === null) { // Snapshot removed, but project/application still set if (this.currentSnapshot) { debug('Active snapshot removed') this.currentSnapshot = null await this.saveProject() } let setApp = false let setProject = false if (utils.hasProperty(newState, 'application')) { if (newState.application !== this.currentApplication) { this.currentApplication = newState.application setApp = true } } if (utils.hasProperty(newState, 'project')) { if (newState.project !== this.currentProject) { this.currentProject = newState.project setProject = true } } if (this.mqttClient) { if (setApp) { this.mqttClient.setProject(null) this.mqttClient.setApplication(this.currentApplication) } if (setProject) { this.mqttClient.setApplication(null) this.mqttClient.setProject(this.currentProject) } } if (setApp || setProject) { await this.saveProject() this.checkIn(2) } if (this.launcher) { await this.launcher.stop(true) this.launcher = undefined } this.currentState = States.STOPPED this.updating = false } else { // Check if any updates are needed let updateSnapshot = false let updateSettings = false const unknownOrStopped = (this.currentState === States.UNKNOWN || this.currentState === States.STOPPED) const snapShotUpdatePending = !!(!this.currentSnapshot && newState.snapshot) const projectUpdatePending = !!(newState.ownerType === 'project' && !this.currentProject && newState.project) const applicationUpdatePending = !!(newState.ownerType === 'application' && !this.currentApplication && newState.application) if (unknownOrStopped && developerMode && snapShotUpdatePending && (projectUpdatePending || applicationUpdatePending)) { info('Developer Mode: no flows found - updating to latest snapshot') this.currentProject = newState.project this.currentApplication = newState.application updateSnapshot = true updateSettings = true } else if (developerMode === false) { if (utils.hasProperty(newState, 'project') && (!this.currentSnapshot || newState.project !== this.currentProject)) { info('New instance assigned') this.currentApplication = null this.currentProject = newState.project // Update everything updateSnapshot = true updateSettings = true } else if (utils.hasProperty(newState, 'application') && (!this.currentSnapshot || newState.application !== this.currentApplication)) { info('New application assigned') this.currentProject = null this.currentApplication = newState.application // Update everything updateSnapshot = true updateSettings = true } else { if (utils.hasProperty(newState, 'snapshot') && (!this.currentSnapshot || newState.snapshot !== this.currentSnapshot.id)) { info('New snapshot available') updateSnapshot = true } // reloadSnapshot is a special case - it is used to force a reload of the current // snapshot following a change from autonomous to developer mode if (newState.reloadSnapshot === true && updateSnapshot === false) { info('Reload snapshot requested') updateSnapshot = true } if (utils.hasProperty(newState, 'settings') && (!this.currentSettings || newState.settings !== this.currentSettings?.hash)) { info('New settings available') updateSettings = true } if (this.currentSettings === null) { updateSettings = true } // If the snapshot is to be updated, the settings must also be updated // this is because snapshot includes special, platform defined environment variables e.g. FF_SNAPSHOT_ID if (updateSnapshot === true) { updateSettings = true } // one time check to see if settings for assistant are missing (added in > v2.6.0+) if (!this.oneTimeAssistantCheck && this.currentSettings && !this.currentSettings?.assistant && this.currentSnapshot?.modules?.['@flowfuse/nr-assistant']) { info('Assistant settings not found') updateSettings = true } this.oneTimeAssistantCheck = true } } else if (developerMode === true) { // We are in developerMode and have been asked to reload settings updateSettings = !!newState.reloadSettings } if (!skipToUpdate && !updateSnapshot && !updateSettings) { // Nothing to update. So long as the target state is not SUSPENDED, // start the launcher with the current config, Snapshot & settings if (!this.launcher && this.currentSnapshot && this.targetState !== States.SUSPENDED) { this.launcher = Launcher.newLauncher(this, this.currentApplication, this.currentProject, this.currentSnapshot, this.currentSettings, this.currentMode) await this.launcher.start() if (this.mqttClient) { this.mqttClient.setProject(this.currentProject) this.mqttClient.setApplication(this.currentApplication) if (developerMode && this.editorToken && this.launcher) { this.mqttClient.startTunnel(this.editorToken, this.editorAffinity) } } this.currentState = this.launcher.state this.checkIn(2) } this.updating = false } else { // At this point of the state machine, we are to stop the launcher and update the snapshot and/or settings // then start the launcher with the new snapshot and/or settings // Stop the launcher if currently running this.currentState = States.UPDATING if (this.launcher) { info('Stopping current snapshot') await this.launcher.stop(false, States.UPDATING) this.launcher = undefined } if (updateSnapshot) { try { this.currentSnapshot = forgeSnapshot || await this.httpClient.getSnapshot() this.retrySetState(false) // success - stop retry timer } catch (err) { if (!this.retrySetStateTimer.isRunning) { this.currentState = States.ERROR warn(`Problem getting snapshot: ${err.toString()}`) debug(err) this.retrySetState(newState) } this.updating = false return } } if (updateSettings) { this.currentSettings = await this.httpClient.getSettings() } if (this.currentSnapshot?.id) { try { await this.saveProject() let optimisticState = States.STOPPED let performStart = true this.currentState = States.UPDATING if (this.targetState === States.SUSPENDED) { this.printAgentStatus('Applying new settings...') performStart = false optimisticState = States.SUSPENDED } else if (this.targetState === States.RUNNING) { this.printAgentStatus('Launching with new settings...') optimisticState = States.STARTING } this.launcher = Launcher.newLauncher(this, this.currentApplication, this.currentProject, this.currentSnapshot, this.currentSettings, this.currentMode) await this.launcher.writeConfiguration({ updateSnapshot, updateSettings }) if (performStart) { await this.launcher?.start() } else { this.launcher = undefined } if (this.mqttClient) { this.mqttClient.setProject(this.currentProject) this.mqttClient.setApplication(this.currentApplication) if (developerMode && this.editorToken && this.launcher) { this.mqttClient.startTunnel(this.editorToken, this.editorAffinity) } } this.currentState = optimisticState this.checkIn(2) } catch (err) { warn(`Error whilst starting Node-RED: ${err.toString()}`) if (this.launcher) { await this.launcher.stop(true, States.ERROR) } this.launcher = undefined this.currentState = States.ERROR this.queuedUpdate = null // we are in error state, clear any queued updates, halt! } } } } if (!this.launcher) { if (this.targetState === States.SUSPENDED) { this.currentState = States.SUSPENDED } else if (isValidState(this.currentState) === false) { this.currentState = States.STOPPED } } else { this.currentState = this.launcher?.state || States.RUNNING } } finally { this.updating = false if (this.queuedUpdate) { const update = this.queuedUpdate this.queuedUpdate = null this.setState(update).catch(err => { this.updating = false warn(`Error whilst processing queued update: ${err.toString()}`) debug(err) }) } } } /** * Check in with the platform to report the current state of the agent * NOTE: If retries is `0` or is not provided, the check-in will be attempted only once. * @param {Number} [retries=0] - (Optional, Default: 0, Max: 5) number of retries to attempt if the agent is busy updating. * @param {Number} [retryDelay=100] - (Optional, Default: 100, Min: 100, Max: 1000) delay in milliseconds between retries */ async checkIn (retries = 0, retryDelay = 100) { retries = Number.isInteger(retries) ? retries : 0 // default to 0 retries retryDelay = Number.isInteger(retryDelay) ? retryDelay : 100 // default to 100ms retryDelay = Math.min(1000, Math.max(100, retryDelay)) // clamp to 100-1000ms retries = Math.min(5, Math.max(0, retries)) // clamp to 0-5 retries if (this.updating && retries > 0) { debug('Cannot check-in: Agent is busy updating. Retrying in 100ms') // call this function again in 100ms setTimeout(() => { this.checkIn(retries - 1) }, retryDelay) return } if (this.mqttClient) { this.mqttClient.checkIn() } else if (this.httpClient) { await this.httpClient.checkIn() } else { debug('No MQTT or HTTP client available to check-in with') } } /** * Schedule a retry of setState * * NOTES: * * Subsequent calls made while timing down will overwrite previous state an restart the timer * * While the retry is busy executing, any calls to this function will be ignored / discarded * @param {Object|false} state - newState data to use. If `false`, stop the timer * @param {Number} time - time, in ms, to wait before retrying */ retrySetState (state) { // Is this a request to stop/clear timer? if (state === false) { this.retrySetStateTimer.stop() return } // if busy actually running callback, leave it to finish & discard this request if (this.retrySetStateTimer.isExecuting) { return } // ensure timer is stopped if (this.retrySetStateTimer.isRunning) { this.retrySetStateTimer.stop() } // setup intervals and jitter: // * awaitCallback: true - wait for the callback to run before scheduling another attempt // i.e. compound the executions to cater for maximum jitter/execution time and avoid re-entry/overlap // * 1st retry 1~6s // * 2nd retry 20~30s // * 3rd retry 40~60s // * 4th retry 60~90s // * subsequent 5m~5.5m const intervals = [1000, 20000, 40000, 60000, 300000] // retries at 1s, 20s, 40s, 60s then every 5m const jitters = [5000, 10000, 20000, 30000] // jitters at 5s, 10s, 20s, then 30s for all future executions // start retry timer this.retrySetStateTimer.start({ interval: intervals, jitter: jitters, awaitCallback: true }, async (_timeSinceLastExecution, callCount) => { info(`Update state retry attempt #${callCount}`) try { await this.setState({ ...state }) } catch (err) { warn(`Error whilst retrying state update: ${err.toString()}`) debug(err) } }) } async saveEditorToken (token, affinity) { const changed = (this.editorToken !== token || this.editorAffinity !== affinity) this.editorToken = token this.editorAffinity = affinity if (changed) { await this.saveProject() } } } module.exports = { newAgent: (config) => new Agent(config), Agent }