@livedesk/hub 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,147 +1,142 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { randomUUID } from 'node:crypto';
2
2
 
3
- export const LIVE_DESK_UPDATE_COMMAND = 'livedesk.client-update';
4
- // 0.1.143 contains the launcher-PID-aware updater. Older clients use the
5
- // Hub-generated legacy command so they can still be upgraded safely.
6
- export const LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION = '0.1.143';
7
- export const LIVE_DESK_UPDATE_TIMEOUT_MS = 180_000;
8
- export const LIVE_DESK_UPDATE_CHECK_INTERVAL_MS = 60_000;
3
+ export const LIVE_DESK_UPDATE_COMMAND = 'livedesk.client-update';
4
+ // 0.1.172 delegates to the exact-version, identity-preserving package
5
+ // supervisor. 0.1.171 still lacks bounded handoff cancellation, so it must
6
+ // use the Hub-owned compatibility bridge too.
7
+ // Older dedicated handlers can spawn the new package but cannot prove that the
8
+ // same device returned, so the Hub-owned compatibility bridge upgrades them.
9
+ export const LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION = '0.1.172';
10
+ export const LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE = 5;
11
+ export const LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS = 480_000;
12
+ export const LIVE_DESK_UPDATE_TIMEOUT_MS = 90 * 60_000;
13
+ export const LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS = 60_000;
14
+ export const LIVE_DESK_UPDATE_CHECK_INTERVAL_MS = 60_000;
15
+ export const LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH = 3900;
9
16
 
10
- function cleanVersion(value) {
11
- return String(value || '').trim().replace(/^v/i, '');
12
- }
13
-
14
- export function compareVersions(left, right) {
15
- const a = cleanVersion(left).split(/[+-]/, 1)[0].split('.').map(part => Number(part) || 0);
16
- const b = cleanVersion(right).split(/[+-]/, 1)[0].split('.').map(part => Number(part) || 0);
17
- const length = Math.max(a.length, b.length, 3);
18
- for (let index = 0; index < length; index += 1) {
19
- const difference = (a[index] || 0) - (b[index] || 0);
20
- if (difference !== 0) return difference;
21
- }
22
- return 0;
17
+ function cleanVersion(value) {
18
+ return String(value || '').trim().replace(/^v/i, '');
19
+ }
20
+
21
+ function parseVersion(value) {
22
+ const cleaned = cleanVersion(value);
23
+ const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
24
+ if (!match) return null;
25
+ return {
26
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
27
+ prerelease: match[4] ? match[4].split('.') : []
28
+ };
29
+ }
30
+
31
+ function comparePrerelease(left, right) {
32
+ if (left.length === 0 && right.length === 0) return 0;
33
+ if (left.length === 0) return 1;
34
+ if (right.length === 0) return -1;
35
+ const length = Math.max(left.length, right.length);
36
+ for (let index = 0; index < length; index += 1) {
37
+ if (left[index] === undefined) return -1;
38
+ if (right[index] === undefined) return 1;
39
+ const leftNumeric = /^\d+$/.test(left[index]);
40
+ const rightNumeric = /^\d+$/.test(right[index]);
41
+ if (leftNumeric && rightNumeric) {
42
+ const difference = Number(left[index]) - Number(right[index]);
43
+ if (difference !== 0) return difference;
44
+ continue;
45
+ }
46
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
47
+ const difference = left[index].localeCompare(right[index], 'en');
48
+ if (difference !== 0) return difference;
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ export function compareVersions(left, right) {
54
+ const a = parseVersion(left);
55
+ const b = parseVersion(right);
56
+ if (!a || !b) return cleanVersion(left).localeCompare(cleanVersion(right), 'en');
57
+ for (let index = 0; index < 3; index += 1) {
58
+ const difference = a.core[index] - b.core[index];
59
+ if (difference !== 0) return difference;
60
+ }
61
+ return comparePrerelease(a.prerelease, b.prerelease);
23
62
  }
24
63
 
25
64
  export function isVersionAtLeast(candidate, required) {
26
65
  return !!cleanVersion(candidate) && !!cleanVersion(required) && compareVersions(candidate, required) >= 0;
27
66
  }
28
67
 
29
- function encodePowerShell(value) {
30
- return Buffer.from(String(value || ''), 'utf16le').toString('base64');
31
- }
32
-
33
- function quotePowerShell(value) {
34
- return `'${String(value ?? '').replaceAll("'", "''")}'`;
35
- }
36
-
37
- function buildWindowsLegacyUpdateCommand({ manager, pair, name, slot, targetVersion }) {
38
- const script = [
39
- '$ErrorActionPreference = "Stop"',
40
- '$cursor = Get-CimInstance Win32_Process -Filter ("ProcessId={0}" -f $PID)',
41
- '$launcherPid = 0',
42
- '$configuredLauncherPid = 0',
43
- 'if ([int]::TryParse([string]$env:LIVEDESK_CLIENT_PARENT_PID, [ref]$configuredLauncherPid) -and $configuredLauncherPid -gt 1) { try { Get-Process -Id $configuredLauncherPid -ErrorAction Stop | Out-Null; $launcherPid = $configuredLauncherPid } catch {} }',
44
- 'if ($launcherPid -le 0) {',
45
- ' for ($depth = 0; $depth -lt 12 -and $cursor; $depth++) {',
46
- ' $parent = Get-CimInstance Win32_Process -Filter ("ProcessId={0}" -f [int]$cursor.ParentProcessId)',
47
- ' if (-not $parent) { break }',
48
- ' $commandLine = [string]$parent.CommandLine',
49
- ' if ($commandLine -match "(?i)(livedesk-client\\.js|livedesk\\.js)") { $launcherPid = [int]$parent.ProcessId; break }',
50
- ' $cursor = $parent',
51
- ' }',
52
- '}',
53
- 'if ($launcherPid -le 0) { throw "LiveDesk client launcher process was not found." }',
54
- `$env:LIVEDESK_CLIENT_MANAGER = ${quotePowerShell(manager)}`,
55
- `$env:LIVEDESK_CLIENT_PAIR_TOKEN = ${quotePowerShell(pair)}`,
56
- `$env:LIVEDESK_CLIENT_NAME = ${quotePowerShell(name)}`,
57
- `$env:LIVEDESK_CLIENT_SLOT = ${quotePowerShell(slot || '')}`,
58
- `$env:LIVEDESK_CLIENT_UPDATE_TARGET_VERSION = ${quotePowerShell(targetVersion)}`,
59
- '$node = [string]$env:LIVEDESK_NODE_EXECUTABLE',
60
- 'if ($node -and -not (Test-Path -LiteralPath $node -PathType Leaf)) { $node = "" }',
61
- 'if (-not $node) { $node = (Get-Command node.exe -ErrorAction SilentlyContinue).Source }',
62
- 'if (-not $node) { throw "LiveDesk node executable was not found." }',
63
- `$env:LIVEDESK_UPDATE_BOOTSTRAP_BASE64 = ${quotePowerShell(Buffer.from(buildUnixClientUpdateBootstrapScript(), 'utf8').toString('base64'))}`,
64
- '$env:LIVEDESK_UPDATE_WAIT_PID = [string]$launcherPid',
65
- '$env:LIVEDESK_UPDATE_AGENT_PID = "0"',
66
- '$bootstrapRunner = "eval(Buffer.from(process.env.LIVEDESK_UPDATE_BOOTSTRAP_BASE64, \'base64\').toString(\'utf8\'))"',
67
- 'Start-Process -FilePath $node -ArgumentList @("-e", $bootstrapRunner) -WindowStyle Hidden',
68
- 'Start-Sleep -Milliseconds 750',
69
- 'Start-Process -FilePath "taskkill.exe" -ArgumentList @("/PID", [string]$launcherPid, "/T", "/F") -WindowStyle Hidden',
70
- ''
71
- ].join('\r\n');
72
- return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShell(script)}`;
73
- }
74
-
75
- function buildUnixClientUpdateBootstrapScript() {
76
- return [
77
- 'const { spawn } = require("node:child_process");',
78
- 'const fs = require("node:fs");',
79
- 'const path = require("node:path");',
80
- 'const waitPid = Number(process.env.LIVEDESK_UPDATE_WAIT_PID || 0);',
81
- 'const agentPid = Number(process.env.LIVEDESK_UPDATE_AGENT_PID || 0);',
82
- 'const isAlive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };',
83
- 'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
84
- 'const waitForExit = async pid => { for (let attempt = 0; attempt < 360 && pid > 1; attempt += 1) { if (!isAlive(pid)) return; await sleep(500); } if (pid > 1 && isAlive(pid)) throw new Error("LiveDesk client process did not exit before update installation."); };',
85
- 'const nodeDir = path.dirname(process.execPath);',
86
- 'const npxCandidates = process.platform === "win32" ? [process.env.LIVEDESK_NPX_EXECUTABLE, path.join(nodeDir, "npx.cmd"), path.join(nodeDir, "npx.exe"), "npx.cmd"] : [process.env.LIVEDESK_NPX_EXECUTABLE, path.join(nodeDir, "npx"), "npx"];',
87
- 'const npx = npxCandidates.find(candidate => !path.isAbsolute(candidate) || fs.existsSync(candidate)) || npxCandidates[npxCandidates.length - 1];',
88
- 'const npmExecPath = String(process.env.npm_execpath || process.env.NPM_EXECPATH || "").trim();',
89
- 'const npxCliCandidates = [process.env.LIVEDESK_NPX_CLI_PATH, npmExecPath ? path.join(path.dirname(npmExecPath), "npx-cli.js") : "", path.join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js")].filter(Boolean);',
90
- 'const npxCli = npxCliCandidates.find(candidate => fs.existsSync(candidate));',
91
- 'const npxArgs = ["-y", "--prefer-online", "livedesk@latest", "--force-role", "client", "--no-login", "--no-open"];',
92
- 'const command = npxCli ? process.execPath : (process.platform === "win32" ? (process.env.ComSpec || "cmd.exe") : npx);',
93
- 'const commandArgs = npxCli ? [npxCli, ...npxArgs] : (process.platform === "win32" ? ["/d", "/s", "/c", "call \\"" + npx + "\\" " + npxArgs.join(" ")] : npxArgs);',
94
- 'process.env.LIVEDESK_SKIP_BROWSER_OPEN = "1";',
95
- '(async () => { await waitForExit(waitPid); await waitForExit(agentPid); const updater = spawn(command, commandArgs, { detached: true, stdio: "ignore", windowsHide: true, env: process.env }); updater.once("error", error => { console.error("LiveDesk updater failed to start: " + error.message); process.exitCode = 1; }); updater.once("spawn", () => updater.unref()); })().catch(error => { console.error("LiveDesk updater failed: " + error.message); process.exitCode = 1; });',
96
- ''
97
- ].join('\n');
98
- }
99
-
100
- function buildUnixLegacyUpdateCommand({ manager, pair, name, slot, targetVersion }) {
101
- const script = [
102
- 'const { execFileSync, spawn } = require("node:child_process");',
103
- 'const fs = require("node:fs");',
104
- 'const path = require("node:path");',
105
- 'const parentOf = pid => { try { return Number(execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" }).trim()) || 0; } catch { return 0; } };',
106
- 'const commandOf = pid => { try { return execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" }); } catch { return ""; } };',
107
- 'const isAlive = pid => { try { process.kill(pid, 0); return true; } catch { return false; } };',
108
- 'const configuredLauncherPid = Number(process.env.LIVEDESK_CLIENT_PARENT_PID || 0);',
109
- 'let launcherPid = Number.isInteger(configuredLauncherPid) && configuredLauncherPid > 1 && isAlive(configuredLauncherPid) ? configuredLauncherPid : 0;',
110
- 'let agentPid = Number(process.env.LIVEDESK_CLIENT_AGENT_PID || 0);',
111
- 'let cursor = parentOf(process.pid);',
112
- 'for (let depth = 0; depth < 12 && cursor; depth++) { const command = commandOf(cursor); if (!agentPid && /livedesk-client-node\\.js|livedesk-client-fast|RemoteAgent\\.Fast/i.test(command)) agentPid = cursor; if (!launcherPid && /livedesk-client\\.js|livedesk\\.js/i.test(command)) launcherPid = cursor; cursor = parentOf(cursor); }',
113
- 'if (launcherPid <= 1) throw new Error("LiveDesk client launcher process was not found.");',
114
- 'const waitPid = launcherPid;',
115
- `process.env.LIVEDESK_CLIENT_MANAGER = ${JSON.stringify(String(manager || ''))};`,
116
- `process.env.LIVEDESK_CLIENT_PAIR_TOKEN = ${JSON.stringify(String(pair || ''))};`,
117
- `process.env.LIVEDESK_CLIENT_NAME = ${JSON.stringify(String(name || ''))};`,
118
- `process.env.LIVEDESK_CLIENT_SLOT = ${JSON.stringify(String(slot || ''))};`,
119
- `process.env.LIVEDESK_CLIENT_UPDATE_TARGET_VERSION = ${JSON.stringify(String(targetVersion || ''))};`,
120
- `const bootstrapScript = ${JSON.stringify(buildUnixClientUpdateBootstrapScript())};`,
121
- 'const bootstrapEnv = { ...process.env, LIVEDESK_UPDATE_WAIT_PID: String(waitPid), LIVEDESK_UPDATE_AGENT_PID: String(agentPid) };',
122
- 'const updater = spawn(process.execPath, ["-e", bootstrapScript], { detached: true, stdio: "ignore", env: bootstrapEnv });',
123
- 'updater.once("error", error => { console.error("LiveDesk updater bootstrap failed to start: " + error.message); process.exitCode = 1; });',
124
- 'updater.once("spawn", () => { updater.unref(); setTimeout(() => { try { process.kill(waitPid, "SIGTERM"); } catch {} if (agentPid > 1 && agentPid !== process.pid) setTimeout(() => { try { process.kill(agentPid, "SIGTERM"); } catch {} }, 500); }, 750); });',
125
- ''
126
- ].join('\n');
127
- return [
128
- 'if [ -n "$LIVEDESK_NODE_EXECUTABLE" ]; then node_bin="$LIVEDESK_NODE_EXECUTABLE"; else node_bin="$(ps -p "$LIVEDESK_CLIENT_PARENT_PID" -o comm= 2>/dev/null | sed "s/^ *//")"; if [ -z "$node_bin" ]; then node_bin="node"; fi; fi',
129
- `"$node_bin" -e ${JSON.stringify(script)}`
130
- ].join('\n');
131
- }
132
-
133
- function buildLegacyUpdateCommand(device, credentials, targetVersion) {
134
- const payload = {
135
- manager: credentials.manager,
136
- pair: credentials.pairToken,
137
- name: device.deviceName || device.hostname || '',
138
- slot: device.slotNumber || '',
139
- targetVersion
140
- };
141
- return ['win32', 'windows'].includes(String(device.platform || '').toLowerCase())
142
- ? buildWindowsLegacyUpdateCommand(payload)
143
- : buildUnixLegacyUpdateCommand(payload);
144
- }
68
+ function encodePowerShell(value) {
69
+ return Buffer.from(String(value || ''), 'utf16le').toString('base64');
70
+ }
71
+
72
+ function buildLegacyPackageSupervisorStarterSource() {
73
+ const source = [
74
+ "const{spawn}=require('node:child_process'),f=require('node:fs'),p=require('node:path'),c=JSON.parse(Buffer.from(process.env.C,'base64')),e={...process.env,LIVEDESK_UPDATE_STARTER_PID:String(process.pid),LIVEDESK_UPDATE_ORIGINAL_CWD:process.cwd()},w=p.join(require('node:os').tmpdir(),'livedesk-update-cwd','client-'+c[0]),u=e.LIVEDESK_NPX_EXECUTABLE,s=process.platform,j=process.execPath,n=u||(s==='win32'?'npx.cmd':'npx'),x=[e.LIVEDESK_NPX_CLI_PATH,e.npm_execpath&&p.join(p.dirname(e.npm_execpath),'npx-cli.js'),u&&p.join(p.dirname(u),'node_modules/npm/bin/npx-cli.js'),p.join(p.dirname(j),'node_modules/npm/bin/npx-cli.js')].find(v=>v&&f.existsSync(v)),a=['-y','--prefer-online','livedesk@'+c[6],'--internal-legacy-client-update'],z=String.fromCharCode(32),q=v=>'\"'+String(v).replaceAll('\"','\"\"')+'\"';if(!(+c[7]>0))process.exit(1);c[7]=String(Date.now()+Number(c[7]));e.C=Buffer.from(JSON.stringify(c)).toString('base64');f.mkdirSync(w,{recursive:true});if(f.readdirSync(w)[0])process.exit(1);let/**/t=w;for(;;t=p.dirname(t)){if(['package.json','node_modules/livedesk/package.json'].some(v=>f.existsSync(p.join(t,v))))process.exit(1);if(p.dirname(t)===t)break}Object.keys(e).forEach(k=>/^(INIT_CWD|npm_config_(local_prefix|workspaces?|include_workspace_root))$/i.test(k)&&Reflect.deleteProperty(e,k));Object.assign(e,{INIT_CWD:w,npm_config_local_prefix:w,npm_config_include_workspace_root:'false',LIVEDESK_UPDATE_NEUTRAL_CWD:w});e.i=x?{c:j,a:[x,...a]}:s==='win32'?{c:e.ComSpec||'cmd.exe',a:['/d','/s','/c','call'+z+q(n)+z+a.map(q).join(z)]}:{c:n,a};e.r=spawn(e.i.c,e.i.a,{cwd:w,env:e,detached:true,stdio:'ignore',windowsHide:true});",
75
+ "e.r.on('error',()=>process.exit(1));e.r.unref();"
76
+ ].join('');
77
+ if (source.includes(' ')) throw new Error('LiveDesk legacy starter source must not contain spaces.');
78
+ return source;
79
+ }
80
+
81
+ function assertLegacyCommandLength(command, platform) {
82
+ if (command.length > LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH) {
83
+ throw new Error(
84
+ `LiveDesk legacy ${platform} update command is ${command.length} characters; `
85
+ + `the compatibility limit is ${LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH}.`
86
+ );
87
+ }
88
+ return command;
89
+ }
90
+
91
+ function buildLegacyPlatformCommand(platform, payload) {
92
+ const environment = [
93
+ String(payload.operationId || ''),
94
+ String(payload.deviceId || ''),
95
+ String(Number(payload.agentPid) || 0),
96
+ cleanVersion(payload.currentProductVersion),
97
+ cleanVersion(payload.currentAgentVersion),
98
+ cleanVersion(payload.targetVersion),
99
+ cleanVersion(payload.targetProductVersion),
100
+ String(Math.floor(Number(payload.updateTimeoutMs) || 0))
101
+ ];
102
+ const configBase64 = Buffer.from(JSON.stringify(environment), 'utf8').toString('base64');
103
+ const starter = buildLegacyPackageSupervisorStarterSource();
104
+ const starterBase64 = Buffer.from(starter, 'utf8').toString('base64');
105
+ const windows = ['win32', 'windows'].includes(String(platform || '').toLowerCase());
106
+ const powerShellStarter = [
107
+ "$ErrorActionPreference='Stop'",
108
+ '$n=[string]$env:LIVEDESK_NODE_EXECUTABLE',
109
+ 'if(-not $n){$n=(Get-Command node.exe -ErrorAction SilentlyContinue).Source}',
110
+ "if(-not $n){throw 'LiveDesk node executable was not found.'}",
111
+ "$j=\"eval(Buffer.from(process.env.S,'base64').toString('utf8'))\"",
112
+ "$p=Start-Process -FilePath $n -ArgumentList @('-e',$j) -WindowStyle Hidden -PassThru",
113
+ "if(-not $p){throw 'LiveDesk update starter failed.'}"
114
+ ].join(';');
115
+ const command = windows
116
+ ? `set C=${configBase64}&set S=${starterBase64}&powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShell(powerShellStarter)}`
117
+ : `C='${configBase64}' S='${starterBase64}' "${'${LIVEDESK_NODE_EXECUTABLE:-node}'}" -e 'eval(Buffer.from(process.env.S,"base64").toString("utf8"))'`;
118
+ return assertLegacyCommandLength(command, windows ? 'Windows' : 'Unix');
119
+ }
120
+
121
+ function buildLegacyUpdateCommand(
122
+ device,
123
+ operationId,
124
+ targetVersion,
125
+ targetProductVersion,
126
+ updateTimeoutMs
127
+ ) {
128
+ const payload = {
129
+ operationId,
130
+ deviceId: device.deviceId || '',
131
+ agentPid: device.pid || 0,
132
+ currentProductVersion: device.productVersion || '',
133
+ currentAgentVersion: device.agentVersion || '',
134
+ targetVersion,
135
+ targetProductVersion,
136
+ updateTimeoutMs
137
+ };
138
+ return buildLegacyPlatformCommand(device.platform, payload);
139
+ }
145
140
 
146
141
  async function fetchLatestPackage(packageName, fetchImpl) {
147
142
  const encodedName = packageName.startsWith('@') ? packageName.replace('/', '%2F') : packageName;
@@ -158,58 +153,121 @@ async function fetchLatestPackage(packageName, fetchImpl) {
158
153
  return { version, package: payload };
159
154
  }
160
155
 
161
- export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch) {
156
+ export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch) {
162
157
  if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for LiveDesk update checks.');
163
158
  const [manager, client] = await Promise.all([
164
159
  fetchLatestPackage('livedesk', fetchImpl),
165
160
  fetchLatestPackage('@livedesk/client', fetchImpl)
166
161
  ]);
167
- return {
168
- latestManagerVersion: manager.version,
169
- latestClientVersion: client.version,
170
- managerPackage: manager.package,
171
- clientPackage: client.package,
172
- checkedAt: new Date().toISOString()
173
- };
174
- }
162
+ const bundledClientVersion = cleanVersion(manager.package?.livedeskClientVersion);
163
+ if (!bundledClientVersion) {
164
+ throw new Error(`livedesk@${manager.version} is missing required livedeskClientVersion release metadata.`);
165
+ }
166
+ return {
167
+ latestManagerVersion: manager.version,
168
+ latestClientVersion: bundledClientVersion,
169
+ registryClientVersion: client.version,
170
+ releaseSkew: compareVersions(client.version, bundledClientVersion) === 0
171
+ ? ''
172
+ : `livedesk@${manager.version} bundles Client ${bundledClientVersion}, while @livedesk/client latest is ${client.version}.`,
173
+ managerPackage: manager.package,
174
+ clientPackage: client.package,
175
+ checkedAt: new Date().toISOString()
176
+ };
177
+ }
175
178
 
176
- function statusForRun(run) {
177
- if (!run) return null;
178
- return {
179
- operationId: run.operationId,
180
- state: run.state,
181
- startedAt: run.startedAt,
182
- updatedAt: run.updatedAt,
183
- targetCount: run.targets.length,
184
- completedCount: run.targets.filter(target => target.state === 'completed').length,
185
- waitingCount: run.targets.filter(target => target.state === 'waiting').length,
186
- failedCount: run.targets.filter(target => target.state === 'failed').length,
187
- error: run.error || '',
188
- targets: run.targets.map(target => ({ ...target }))
189
- };
190
- }
179
+ function statusForRun(run) {
180
+ if (!run) return null;
181
+ const completedCount = run.targets.filter(target => target.state === 'completed').length;
182
+ const failedCount = run.targets.filter(target => target.state === 'failed').length;
183
+ const queuedCount = run.targets.filter(target => target.state === 'queued').length;
184
+ const pendingOfflineCount = run.targets.filter(target => target.state === 'pending-offline').length;
185
+ const activeCount = run.targets.filter(target => target.state === 'waiting').length;
186
+ return {
187
+ operationId: run.operationId,
188
+ state: run.state,
189
+ startedAt: run.startedAt,
190
+ updatedAt: run.updatedAt,
191
+ targetCount: run.targets.length,
192
+ completedCount,
193
+ waitingCount: run.targets.length - completedCount - failedCount,
194
+ activeCount,
195
+ queuedCount,
196
+ pendingOfflineCount,
197
+ failedCount,
198
+ batchSize: run.batchSize,
199
+ clientRestartStabilityMs: run.clientRestartStabilityMs,
200
+ latestManagerVersion: run.latestManagerVersion,
201
+ latestClientVersion: run.latestClientVersion,
202
+ error: run.error || '',
203
+ targets: run.targets.map(target => ({ ...target }))
204
+ };
205
+ }
191
206
 
192
- function connectedDevices(remoteHub) {
207
+ function connectedDevices(remoteHub) {
193
208
  return remoteHub.listDevices({ includeDataUrl: false })
194
209
  .filter(device => device.connected === true && device.synthetic !== true)
195
210
  .slice(0, 500);
196
- }
197
-
198
- export function createLiveDeskUpdateManager({
211
+ }
212
+
213
+ function knownOfflineDevices(remoteHub) {
214
+ return remoteHub.listDevices({ includeDataUrl: false })
215
+ .filter(device => device.connected !== true && device.synthetic !== true)
216
+ .slice(0, 500);
217
+ }
218
+
219
+ function deviceNeedsUpdate(device, latestManagerVersion, latestClientVersion) {
220
+ return !isVersionAtLeast(device?.productVersion, latestManagerVersion)
221
+ || !isVersionAtLeast(device?.agentVersion, latestClientVersion);
222
+ }
223
+
224
+ function resetReconnectCandidate(target) {
225
+ target.candidateSessionId = '';
226
+ target.candidatePid = 0;
227
+ target.candidateConnectedAt = '';
228
+ target.candidateProductVersion = '';
229
+ target.candidateAgentVersion = '';
230
+ target.candidateSince = '';
231
+ target.stabilityDeadlineAt = '';
232
+ }
233
+
234
+ export function createLiveDeskUpdateManager({
199
235
  remoteHub,
200
236
  currentManagerVersion,
201
- currentClientVersion,
202
- restartSupported = false,
203
- requestHubRestart,
204
- fetchImpl = globalThis.fetch,
205
- now = () => Date.now()
206
- }) {
237
+ currentClientVersion,
238
+ restartSupported = false,
239
+ requestHubRestart,
240
+ fetchImpl = globalThis.fetch,
241
+ now = () => Date.now(),
242
+ clientBatchSize = LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE,
243
+ targetTimeoutMs = LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS,
244
+ operationTimeoutMs = LIVE_DESK_UPDATE_TIMEOUT_MS,
245
+ clientRestartStabilityMs = LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS
246
+ }) {
207
247
  let latestRelease = null;
208
248
  let checkError = '';
209
249
  let checkPromise = null;
210
- let run = null;
211
- let checkTimer = null;
212
- let runTimer = null;
250
+ let run = null;
251
+ let checkTimer = null;
252
+ let runTimer = null;
253
+ const effectiveClientBatchSize = Math.max(
254
+ 1,
255
+ Math.min(50, Math.floor(Number(clientBatchSize) || LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE))
256
+ );
257
+ const effectiveTargetTimeoutMs = Math.max(
258
+ 10_000,
259
+ Number(targetTimeoutMs) || LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS
260
+ );
261
+ const effectiveOperationTimeoutMs = Math.max(
262
+ effectiveTargetTimeoutMs,
263
+ Number(operationTimeoutMs) || LIVE_DESK_UPDATE_TIMEOUT_MS
264
+ );
265
+ const effectiveClientRestartStabilityMs = Math.max(
266
+ 0,
267
+ Number.isFinite(Number(clientRestartStabilityMs))
268
+ ? Number(clientRestartStabilityMs)
269
+ : LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS
270
+ );
213
271
 
214
272
  const touch = () => {
215
273
  if (run) run.updatedAt = new Date(now()).toISOString();
@@ -234,45 +292,110 @@ export function createLiveDeskUpdateManager({
234
292
  };
235
293
 
236
294
  const getStatus = () => {
237
- const managerUpdateAvailable = !!latestRelease
238
- && compareVersions(latestRelease.latestManagerVersion, currentManagerVersion) > 0;
239
- const clientDevices = connectedDevices(remoteHub);
240
- const outdatedClientDevices = latestRelease
241
- ? clientDevices.filter(device => !isVersionAtLeast(device.agentVersion, latestRelease.latestClientVersion))
242
- : [];
243
- const clientPackageUpdateAvailable = !!latestRelease
244
- && compareVersions(latestRelease.latestClientVersion, currentClientVersion) > 0;
295
+ const managerUpdateAvailable = !!latestRelease
296
+ && compareVersions(latestRelease.latestManagerVersion, currentManagerVersion) > 0;
297
+ const clientDevices = connectedDevices(remoteHub);
298
+ const outdatedClientDevices = latestRelease
299
+ ? clientDevices.filter(device => deviceNeedsUpdate(
300
+ device,
301
+ latestRelease.latestManagerVersion,
302
+ latestRelease.latestClientVersion
303
+ ))
304
+ : [];
305
+ const pendingOfflineClientCount = latestRelease
306
+ ? knownOfflineDevices(remoteHub).filter(device => deviceNeedsUpdate(
307
+ device,
308
+ latestRelease.latestManagerVersion,
309
+ latestRelease.latestClientVersion
310
+ )).length
311
+ : 0;
312
+ const clientPackageUpdateAvailable = !!latestRelease
313
+ && compareVersions(latestRelease.latestClientVersion, currentClientVersion) > 0;
245
314
  const clientUpdateAvailable = clientPackageUpdateAvailable || outdatedClientDevices.length > 0;
246
315
  const updateAvailable = managerUpdateAvailable || clientUpdateAvailable;
247
316
  const activeRun = statusForRun(run);
248
317
  return {
249
318
  currentVersion: String(currentManagerVersion || ''),
250
319
  currentClientVersion: String(currentClientVersion || ''),
251
- latestVersion: latestRelease?.latestManagerVersion || '',
252
- latestClientVersion: latestRelease?.latestClientVersion || '',
253
- updateAvailable,
320
+ latestVersion: latestRelease?.latestManagerVersion || '',
321
+ latestClientVersion: latestRelease?.latestClientVersion || '',
322
+ registryClientVersion: latestRelease?.registryClientVersion || '',
323
+ releaseSkew: latestRelease?.releaseSkew || '',
324
+ updateAvailable,
254
325
  managerUpdateAvailable,
255
326
  clientUpdateAvailable,
256
- clientPackageUpdateAvailable,
257
- outdatedClientCount: outdatedClientDevices.length,
258
- checkedAt: latestRelease?.checkedAt || '',
327
+ clientPackageUpdateAvailable,
328
+ outdatedClientCount: outdatedClientDevices.length,
329
+ pendingOfflineClientCount,
330
+ checkedAt: latestRelease?.checkedAt || '',
259
331
  checkError,
260
332
  restartSupported: !!restartSupported,
261
333
  canApply: updateAvailable && (!managerUpdateAvailable && !clientPackageUpdateAvailable || !!restartSupported),
262
- ...(activeRun || { state: 'idle', operationId: '', targetCount: 0, completedCount: 0, waitingCount: 0, failedCount: 0, targets: [] })
334
+ ...(activeRun || {
335
+ state: 'idle',
336
+ operationId: '',
337
+ targetCount: 0,
338
+ completedCount: 0,
339
+ waitingCount: 0,
340
+ activeCount: 0,
341
+ queuedCount: 0,
342
+ pendingOfflineCount: 0,
343
+ failedCount: 0,
344
+ batchSize: effectiveClientBatchSize,
345
+ clientRestartStabilityMs: effectiveClientRestartStabilityMs,
346
+ targets: []
347
+ })
263
348
  };
264
349
  };
265
350
 
266
- const failRun = (message) => {
267
- if (!run) return;
268
- run.state = 'failed';
269
- run.error = String(message || 'LiveDesk update failed.');
270
- touch();
271
- if (runTimer) {
272
- clearInterval(runTimer);
273
- runTimer = null;
274
- }
275
- };
351
+ const failRun = (message) => {
352
+ if (!run) return;
353
+ run.state = 'failed';
354
+ run.error = String(message || 'LiveDesk update failed.');
355
+ for (const target of run.targets) {
356
+ if (target.state === 'completed') continue;
357
+ target.state = 'failed';
358
+ if (!target.error) {
359
+ target.error = `Rollout stopped: ${run.error}`;
360
+ }
361
+ }
362
+ touch();
363
+ if (runTimer) {
364
+ clearInterval(runTimer);
365
+ runTimer = null;
366
+ }
367
+ };
368
+
369
+ const reconcileHubRestartResult = (restartResult) => {
370
+ if (!run || run.state !== 'hub-restart-requested') return false;
371
+ const operationId = String(restartResult?.operationId || '').trim();
372
+ if (!operationId || operationId !== run.operationId) return false;
373
+ const stage = String(restartResult?.stage || '').trim();
374
+ const outcome = String(restartResult?.outcome || '').trim();
375
+ if (outcome !== 'failed' || !['failed', 'preflight-failed'].includes(stage)) return false;
376
+ failRun(
377
+ restartResult?.error
378
+ || (stage === 'preflight-failed'
379
+ ? 'Hub update restart preflight failed before shutdown.'
380
+ : 'Hub update restart failed.')
381
+ );
382
+ return true;
383
+ };
384
+
385
+ const retrySettlingTargets = () => {
386
+ if (!run || run.state !== 'failed') return [];
387
+ const currentTime = now();
388
+ const devices = new Map(
389
+ remoteHub.listDevices({ includeDataUrl: false })
390
+ .map(device => [String(device.deviceId), device])
391
+ );
392
+ return run.targets.filter(target => {
393
+ if (target.state !== 'failed' || !target.dispatchedAt) return false;
394
+ const deadline = Date.parse(target.deadlineAt || '');
395
+ if (!(deadline > currentTime)) return false;
396
+ return devices.get(target.deviceId)?.connected !== true;
397
+ });
398
+ };
276
399
 
277
400
  const requestRestart = () => {
278
401
  if (!restartSupported) {
@@ -281,8 +404,8 @@ export function createLiveDeskUpdateManager({
281
404
  }
282
405
  const result = requestHubRestart?.({
283
406
  operationId: run?.operationId || '',
284
- latestVersion: latestRelease?.latestManagerVersion || '',
285
- latestClientVersion: latestRelease?.latestClientVersion || '',
407
+ latestVersion: run?.latestManagerVersion || latestRelease?.latestManagerVersion || '',
408
+ latestClientVersion: run?.latestClientVersion || latestRelease?.latestClientVersion || '',
286
409
  // Current versions update every connected Client before the Hub exits.
287
410
  // Older Hub versions did the reverse and set this flag themselves; the
288
411
  // new server still supports that one-time migration path.
@@ -299,75 +422,214 @@ export function createLiveDeskUpdateManager({
299
422
  return true;
300
423
  };
301
424
 
302
- const verifyTargets = () => {
303
- if (!run || run.state !== 'waiting-for-clients') return;
304
- const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
305
- for (const target of run.targets) {
306
- if (target.state === 'failed' || target.state === 'completed') continue;
307
- const device = devices.get(target.deviceId);
308
- if (device?.connected === true && isVersionAtLeast(device.agentVersion, run.latestClientVersion)) {
309
- target.state = 'completed';
310
- target.currentVersion = String(device.agentVersion || '');
311
- target.completedAt = new Date(now()).toISOString();
312
- } else {
313
- target.state = 'waiting';
314
- }
315
- }
316
- touch();
317
- if (run.targets.every(target => target.state === 'completed')) {
318
- if (runTimer) {
319
- clearInterval(runTimer);
320
- runTimer = null;
321
- }
322
- if (run.needsHubRestart) {
323
- requestRestart();
324
- } else {
325
- run.state = 'clients-updated';
326
- touch();
327
- }
328
- return;
329
- }
330
- if (now() - Date.parse(run.startedAt) >= LIVE_DESK_UPDATE_TIMEOUT_MS) {
331
- failRun(`Timed out waiting for ${run.targets.filter(target => target.state !== 'completed').length} client(s) to reconnect with ${run.latestClientVersion}.`);
425
+ const dispatchTarget = (target, device) => {
426
+ const dispatchedAtEpochMs = now();
427
+ const updateDeadlineEpochMs = dispatchedAtEpochMs + effectiveTargetTimeoutMs;
428
+ const dispatchedSessionId = String(device?.sessionId || '').trim();
429
+ if (!dispatchedSessionId) {
430
+ target.state = 'failed';
431
+ target.error = 'client-update-session-unavailable';
432
+ return false;
433
+ }
434
+ target.dispatchedSessionId = dispatchedSessionId;
435
+ target.dispatchedPid = Math.max(0, Math.floor(Number(device?.pid) || 0));
436
+ resetReconnectCandidate(target);
437
+ const supportsDedicated = device.capabilities?.clientUpdate === true
438
+ && isVersionAtLeast(device.agentVersion, LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION);
439
+ const payload = {
440
+ targetVersion: run.latestClientVersion,
441
+ targetProductVersion: run.latestManagerVersion,
442
+ managerVersion: run.latestManagerVersion,
443
+ operationId: run.operationId,
444
+ updateTimeoutMs: effectiveTargetTimeoutMs
445
+ };
446
+ const result = supportsDedicated
447
+ ? remoteHub.sendCommand(device.deviceId, { command: LIVE_DESK_UPDATE_COMMAND, payload })
448
+ : remoteHub.sendLegacyClientUpdate(device.deviceId, {
449
+ command: buildLegacyUpdateCommand(
450
+ device,
451
+ run.operationId,
452
+ run.latestClientVersion,
453
+ run.latestManagerVersion,
454
+ effectiveTargetTimeoutMs
455
+ ),
456
+ timeoutMs: 30_000
457
+ });
458
+ if (result?.ok !== true) {
459
+ const dispatchError = String(result?.error || 'client-update-dispatch-failed');
460
+ if (['device-not-connected', 'device-not-found'].includes(dispatchError)) {
461
+ target.state = 'pending-offline';
462
+ target.method = '';
463
+ target.commandId = '';
464
+ target.dispatchedAt = '';
465
+ target.deadlineAt = '';
466
+ target.dispatchedSessionId = '';
467
+ target.dispatchedPid = 0;
468
+ resetReconnectCandidate(target);
469
+ target.error = '';
470
+ return 'pending-offline';
471
+ }
472
+ target.state = 'failed';
473
+ target.error = dispatchError;
474
+ return false;
332
475
  }
333
- };
334
-
335
- const dispatchTarget = (target, credentials) => {
336
- const device = target.device;
337
- const supportsDedicated = device.capabilities?.clientUpdate === true
338
- && isVersionAtLeast(device.agentVersion, LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION);
339
- const payload = {
340
- targetVersion: run.latestClientVersion,
341
- managerVersion: run.latestManagerVersion,
342
- operationId: run.operationId
343
- };
344
- const result = supportsDedicated
345
- ? remoteHub.sendCommand(device.deviceId, { command: LIVE_DESK_UPDATE_COMMAND, payload })
346
- : remoteHub.sendLegacyClientUpdate(device.deviceId, {
347
- command: buildLegacyUpdateCommand(device, credentials, run.latestClientVersion),
348
- timeoutMs: 30_000
349
- });
350
- if (result?.ok !== true) {
351
- target.state = 'failed';
352
- target.error = String(result?.error || 'client-update-dispatch-failed');
353
- return false;
354
- }
355
- target.state = 'waiting';
356
- target.method = supportsDedicated ? 'dedicated' : 'legacy-command-run';
357
- target.commandId = String(result.commandId || result.taskId || '');
358
- return true;
359
- };
360
-
361
- const startUpdate = async () => {
362
- if (run && ['dispatching', 'waiting-for-clients', 'hub-restart-requested'].includes(run.state)) {
363
- return { ok: true, ...getStatus() };
364
- }
365
- const release = latestRelease || await checkLatest();
476
+ target.state = 'waiting';
477
+ target.method = supportsDedicated ? 'dedicated' : 'legacy-command-run';
478
+ target.commandId = String(result.commandId || result.taskId || '');
479
+ target.dispatchedAt = new Date(dispatchedAtEpochMs).toISOString();
480
+ target.deadlineAt = new Date(updateDeadlineEpochMs).toISOString();
481
+ return true;
482
+ };
483
+
484
+ const dispatchQueuedTargets = () => {
485
+ if (!run || run.state !== 'waiting-for-clients') return true;
486
+ const activeCount = run.targets.filter(target => target.state === 'waiting').length;
487
+ let availableSlots = Math.max(0, run.batchSize - activeCount);
488
+ if (availableSlots <= 0) return true;
489
+ const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
490
+ for (const target of run.targets) {
491
+ if (availableSlots <= 0) break;
492
+ if (!['queued', 'pending-offline'].includes(target.state)) continue;
493
+ const device = devices.get(target.deviceId);
494
+ if (!device?.connected) {
495
+ target.state = 'pending-offline';
496
+ continue;
497
+ }
498
+ target.state = 'queued';
499
+ let dispatchResult;
500
+ try {
501
+ dispatchResult = dispatchTarget(target, device);
502
+ } catch (error) {
503
+ target.state = 'failed';
504
+ target.error = String(error?.message || error || 'client-update-command-build-failed').slice(0, 500);
505
+ failRun(`Client ${target.deviceName || target.deviceId} could not be scheduled: ${target.error}`);
506
+ return false;
507
+ }
508
+ if (dispatchResult === false) {
509
+ failRun(`Client ${target.deviceName || target.deviceId} could not be scheduled: ${target.error}`);
510
+ return false;
511
+ }
512
+ if (dispatchResult === true) {
513
+ availableSlots -= 1;
514
+ }
515
+ }
516
+ touch();
517
+ return true;
518
+ };
519
+
520
+ const verifyTargets = () => {
521
+ if (!run || run.state !== 'waiting-for-clients') return;
522
+ const currentTime = now();
523
+ if (currentTime - Date.parse(run.startedAt) >= effectiveOperationTimeoutMs) {
524
+ const unfinishedCount = run.targets.filter(target => target.state !== 'completed').length;
525
+ failRun(`Timed out waiting for ${unfinishedCount} client(s) to finish the rollout.`);
526
+ return;
527
+ }
528
+ const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
529
+ for (const target of run.targets) {
530
+ if (target.state === 'failed' || target.state === 'completed') continue;
531
+ const device = devices.get(target.deviceId);
532
+ if (target.state === 'waiting') {
533
+ const sessionId = String(device?.sessionId || '').trim();
534
+ const connectedAt = String(device?.connectedAt || '');
535
+ const connectedAtEpochMs = Date.parse(connectedAt);
536
+ const dispatchedAtEpochMs = Date.parse(target.dispatchedAt || '');
537
+ const candidatePid = Math.max(0, Math.floor(Number(device?.pid) || 0));
538
+ const candidateProductVersion = String(device?.productVersion || '');
539
+ const candidateAgentVersion = String(device?.agentVersion || '');
540
+ const qualifiesForStability = device?.connected === true
541
+ && !!sessionId
542
+ && candidatePid > 1
543
+ && sessionId !== target.dispatchedSessionId
544
+ && connectedAtEpochMs >= dispatchedAtEpochMs
545
+ && !deviceNeedsUpdate(device, run.latestManagerVersion, run.latestClientVersion);
546
+ const sameCandidate = qualifiesForStability
547
+ && target.candidateSessionId === sessionId
548
+ && target.candidatePid === candidatePid
549
+ && target.candidateConnectedAt === connectedAt
550
+ && target.candidateProductVersion === candidateProductVersion
551
+ && target.candidateAgentVersion === candidateAgentVersion;
552
+
553
+ if (!qualifiesForStability) {
554
+ resetReconnectCandidate(target);
555
+ } else if (!sameCandidate) {
556
+ target.candidateSessionId = sessionId;
557
+ target.candidatePid = candidatePid;
558
+ target.candidateConnectedAt = connectedAt;
559
+ target.candidateProductVersion = candidateProductVersion;
560
+ target.candidateAgentVersion = candidateAgentVersion;
561
+ target.candidateSince = new Date(currentTime).toISOString();
562
+ target.stabilityDeadlineAt = new Date(
563
+ currentTime + run.clientRestartStabilityMs
564
+ ).toISOString();
565
+ }
566
+
567
+ if (qualifiesForStability
568
+ && target.candidateSessionId === sessionId
569
+ && currentTime >= Date.parse(target.stabilityDeadlineAt || '')) {
570
+ target.state = 'completed';
571
+ target.currentVersion = String(device.agentVersion || '');
572
+ target.currentProductVersion = String(device.productVersion || '');
573
+ target.completedAt = new Date(currentTime).toISOString();
574
+ continue;
575
+ }
576
+ }
577
+ if (target.state === 'waiting'
578
+ && Date.parse(target.deadlineAt || '') > 0
579
+ && currentTime >= Date.parse(target.deadlineAt)) {
580
+ target.state = 'failed';
581
+ target.error = `Timed out waiting for product ${run.latestManagerVersion} / Client ${run.latestClientVersion}.`;
582
+ failRun(`Client ${target.deviceName || target.deviceId} did not restart on the requested versions within ${Math.round(effectiveTargetTimeoutMs / 1000)} seconds.`);
583
+ return;
584
+ }
585
+ if (target.state === 'pending-offline' && device?.connected === true) {
586
+ target.state = 'queued';
587
+ }
588
+ }
589
+ if (!dispatchQueuedTargets()) return;
590
+ touch();
591
+ if (run.targets.every(target => target.state === 'completed')) {
592
+ if (runTimer) {
593
+ clearInterval(runTimer);
594
+ runTimer = null;
595
+ }
596
+ if (run.needsHubRestart) {
597
+ requestRestart();
598
+ } else {
599
+ run.state = 'clients-updated';
600
+ touch();
601
+ }
602
+ return;
603
+ }
604
+ };
605
+
606
+ const startUpdate = async () => {
607
+ if (run && ['dispatching', 'waiting-for-clients', 'hub-restart-requested'].includes(run.state)) {
608
+ return { ok: true, ...getStatus() };
609
+ }
610
+ const settlingTargets = retrySettlingTargets();
611
+ if (settlingTargets.length > 0) {
612
+ const names = settlingTargets
613
+ .slice(0, 3)
614
+ .map(target => target.deviceName || target.deviceId)
615
+ .join(', ');
616
+ const remaining = settlingTargets.length > 3 ? ` and ${settlingTargets.length - 3} more` : '';
617
+ return {
618
+ ...getStatus(),
619
+ ok: false,
620
+ error: `The previous rollout is still settling for offline Client(s): ${names}${remaining}. Retry after they reconnect or their restart deadline passes.`
621
+ };
622
+ }
623
+ const release = latestRelease || await checkLatest();
366
624
  if (!release) return { ok: false, error: checkError || 'LiveDesk update check failed.', ...getStatus() };
367
625
  const managerNeedsUpdate = compareVersions(release.latestManagerVersion, currentManagerVersion) > 0;
368
626
  const clientPackageNeedsUpdate = compareVersions(release.latestClientVersion, currentClientVersion) > 0;
369
627
  const connected = connectedDevices(remoteHub);
370
- const outdatedClients = connected.filter(device => !isVersionAtLeast(device.agentVersion, release.latestClientVersion));
628
+ const outdatedClients = connected.filter(device => deviceNeedsUpdate(
629
+ device,
630
+ release.latestManagerVersion,
631
+ release.latestClientVersion
632
+ ));
371
633
  const needsHubRestart = managerNeedsUpdate || clientPackageNeedsUpdate;
372
634
  if (!managerNeedsUpdate && !clientPackageNeedsUpdate && outdatedClients.length === 0) {
373
635
  return { ok: true, state: 'clients-updated', ...getStatus() };
@@ -376,57 +638,77 @@ export function createLiveDeskUpdateManager({
376
638
  return { ok: false, error: 'Hub launcher restart is unavailable. Start LiveDesk through npx livedesk@latest.', ...getStatus() };
377
639
  }
378
640
  const targets = outdatedClients;
379
- const credentials = remoteHub.getStatus({ includeSecrets: true });
380
- run = {
641
+ run = {
381
642
  operationId: randomUUID(),
382
643
  state: 'dispatching',
383
644
  startedAt: new Date(now()).toISOString(),
384
645
  updatedAt: new Date(now()).toISOString(),
385
646
  latestManagerVersion: release.latestManagerVersion,
386
- latestClientVersion: release.latestClientVersion,
647
+ latestClientVersion: release.latestClientVersion,
387
648
  needsHubRestart,
388
- error: '',
389
- targets: targets.map(device => ({
649
+ batchSize: effectiveClientBatchSize,
650
+ clientRestartStabilityMs: effectiveClientRestartStabilityMs,
651
+ error: '',
652
+ targets: targets.map(device => ({
390
653
  deviceId: String(device.deviceId || ''),
391
- deviceName: String(device.deviceName || device.hostname || device.deviceId || ''),
392
- oldVersion: String(device.agentVersion || ''),
393
- currentVersion: '',
394
- state: 'queued',
395
- method: '',
396
- commandId: '',
397
- error: '',
398
- device
399
- }))
400
- };
401
- for (const target of run.targets) dispatchTarget(target, credentials);
402
- run.targets = run.targets.map(({ device, ...target }) => target);
403
- touch();
404
- if (run.targets.some(target => target.state === 'failed')) {
405
- failRun('One or more clients could not be scheduled for update.');
406
- return { ok: false, error: run.error, ...getStatus() };
407
- }
408
- if (run.targets.length === 0) {
409
- if (run.needsHubRestart) {
410
- requestRestart();
411
- } else {
654
+ deviceName: String(device.deviceName || device.hostname || device.deviceId || ''),
655
+ oldVersion: String(device.agentVersion || ''),
656
+ oldProductVersion: String(device.productVersion || ''),
657
+ currentVersion: '',
658
+ currentProductVersion: '',
659
+ state: 'queued',
660
+ method: '',
661
+ commandId: '',
662
+ dispatchedAt: '',
663
+ deadlineAt: '',
664
+ dispatchedSessionId: '',
665
+ dispatchedPid: 0,
666
+ candidateSessionId: '',
667
+ candidatePid: 0,
668
+ candidateConnectedAt: '',
669
+ candidateProductVersion: '',
670
+ candidateAgentVersion: '',
671
+ candidateSince: '',
672
+ stabilityDeadlineAt: '',
673
+ completedAt: '',
674
+ error: ''
675
+ }))
676
+ };
677
+ touch();
678
+ if (run.targets.length === 0) {
679
+ if (run.needsHubRestart) {
680
+ if (!requestRestart()) {
681
+ return { ok: false, error: run.error, ...getStatus() };
682
+ }
683
+ } else {
412
684
  run.state = 'clients-updated';
413
685
  touch();
414
686
  }
415
687
  return { ok: true, ...getStatus() };
416
- }
417
- run.state = 'waiting-for-clients';
418
- runTimer = setInterval(verifyTargets, 1000);
688
+ }
689
+ run.state = 'waiting-for-clients';
690
+ if (!dispatchQueuedTargets()) {
691
+ return { ok: false, error: run.error, ...getStatus() };
692
+ }
693
+ runTimer = setInterval(verifyTargets, 1000);
419
694
  runTimer.unref?.();
420
695
  verifyTargets();
421
696
  return { ok: true, ...getStatus() };
422
- };
423
-
424
- const handleRemoteEvent = (type, event) => {
425
- if (!run || run.state !== 'waiting-for-clients' || type !== 'RemoteCommandResult') return;
697
+ };
698
+
699
+ const handleRemoteEvent = (type, event) => {
700
+ if (!run || run.state !== 'waiting-for-clients') return;
701
+ if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
702
+ verifyTargets();
703
+ return;
704
+ }
705
+ if (type !== 'RemoteCommandResult') return;
426
706
  const deviceId = String(event?.device?.deviceId || '');
427
707
  const commandId = String(event?.commandId || '');
428
708
  const target = run.targets.find(item => item.deviceId === deviceId && item.commandId === commandId);
429
- if (!target || !['dedicated', 'legacy-command-run'].includes(target.method)) return;
709
+ if (!target
710
+ || target.state !== 'waiting'
711
+ || !['dedicated', 'legacy-command-run'].includes(target.method)) return;
430
712
  const result = event?.result;
431
713
  if (event?.error || result?.ok === false || result?.status === 'failed' || result?.status === 'rejected') {
432
714
  target.state = 'failed';
@@ -444,10 +726,11 @@ export function createLiveDeskUpdateManager({
444
726
 
445
727
  return {
446
728
  checkLatest,
447
- getStatus,
448
- startUpdate,
449
- handleRemoteEvent,
450
- close() {
729
+ getStatus,
730
+ startUpdate,
731
+ handleRemoteEvent,
732
+ reconcileHubRestartResult,
733
+ close() {
451
734
  if (checkTimer) clearInterval(checkTimer);
452
735
  if (runTimer) clearInterval(runTimer);
453
736
  checkTimer = null;