loopengine 0.1.75 → 0.1.76
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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/web/global-config-page.js +101 -1
- package/dist/web/upgrade-admin.d.ts +43 -0
- package/dist/web/upgrade-admin.js +172 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilitie
|
|
|
79
79
|
export type { PublicAbilitySearchResult } from './web/abilities-admin.js';
|
|
80
80
|
export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
|
|
81
81
|
export { loopengineVersion } from './core/version.js';
|
|
82
|
+
export { describeUpgrade, runUpgrade, UpgradeError } from './web/upgrade-admin.js';
|
|
82
83
|
export type { ModelProviderInfo, AgentModelUsage, ModelsView, GatewayProviderInfo, GatewaysView } from './web/global-config.js';
|
|
83
84
|
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
84
85
|
export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
|
package/dist/index.js
CHANGED
|
@@ -70,5 +70,6 @@ export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities,
|
|
|
70
70
|
export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
|
|
71
71
|
export { describeModelProviders, describeGateways, setModelProviderKey, clearModelProviderKey, ModelKeyError } from './web/global-config.js';
|
|
72
72
|
export { loopengineVersion } from './core/version.js';
|
|
73
|
+
export { describeUpgrade, runUpgrade, UpgradeError } from './web/upgrade-admin.js';
|
|
73
74
|
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
74
75
|
export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
|
|
@@ -302,9 +302,108 @@ ${consoleShellScript}
|
|
|
302
302
|
];
|
|
303
303
|
return '<section><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody>' +
|
|
304
304
|
rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
|
|
305
|
-
'</tbody></table></section>'
|
|
305
|
+
'</tbody></table></section>' +
|
|
306
|
+
'<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
// ---- Upgrade ----
|
|
310
|
+
|
|
311
|
+
var UPGRADE_NOTE = 'Updates this server' + String.fromCharCode(8217) + 's LoopEngine code and restarts it, which takes a minute or two. ' +
|
|
312
|
+
'Your agents, abilities, conversations, approvals, schedules and settings are kept. ' +
|
|
313
|
+
'A template file you edited (such as adapters/http.ts) is left exactly as it is.';
|
|
314
|
+
|
|
315
|
+
function upgradeBox() { return document.getElementById('upgradeBox'); }
|
|
316
|
+
|
|
317
|
+
function renderUpgrade(u) {
|
|
318
|
+
var box = upgradeBox();
|
|
319
|
+
if (!box) return;
|
|
320
|
+
if (u.unavailableReason && !u.updateAvailable) {
|
|
321
|
+
box.innerHTML = '<p class="hint">' + escapeHtml(u.unavailableReason) + '</p>';
|
|
322
|
+
} else if (!u.updateAvailable) {
|
|
323
|
+
box.innerHTML = '<p>You' + String.fromCharCode(8217) + 're on the latest version, <code>' + escapeHtml(u.current) + '</code>.</p>';
|
|
324
|
+
} else if (u.unavailableReason) {
|
|
325
|
+
box.innerHTML = '<p>LoopEngine <code>' + escapeHtml(u.latest) + '</code> is available (this server runs <code>' + escapeHtml(u.current) + '</code>).</p><p class="hint">' + escapeHtml(u.unavailableReason) + '</p>';
|
|
326
|
+
} else if (!u.canUpgrade) {
|
|
327
|
+
box.innerHTML = '<p>LoopEngine <code>' + escapeHtml(u.latest) + '</code> is available (this server runs <code>' + escapeHtml(u.current) + '</code>).</p><p class="hint">Set LOOPENGINE_ADMIN_AUTH to upgrade from here, or run <code>npx create-loopengine@latest upgrade</code>.</p>';
|
|
328
|
+
} else {
|
|
329
|
+
box.innerHTML = '<p>LoopEngine <code>' + escapeHtml(u.latest) + '</code> is available (this server runs <code>' + escapeHtml(u.current) + '</code>).</p>' +
|
|
330
|
+
'<p class="hint">' + UPGRADE_NOTE + (u.restartsItself ? '' : ' This server has nothing to restart it, so restart it yourself afterwards.') + '</p>' +
|
|
331
|
+
'<p><button type="button" class="primary" id="upgradeBtn">Upgrade to ' + escapeHtml(u.latest) + '</button></p>' +
|
|
332
|
+
'<div id="upgradeMsg" class="hint"></div>';
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function loadUpgrade() {
|
|
337
|
+
fetch('/config/upgrade')
|
|
338
|
+
.then(function (r) { return r.json(); })
|
|
339
|
+
.then(renderUpgrade)
|
|
340
|
+
.catch(function (err) {
|
|
341
|
+
var box = upgradeBox();
|
|
342
|
+
if (box) box.innerHTML = '<p class="error">Could not check for updates: ' + escapeHtml(err.message) + '</p>';
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function upgradeMsg(html, bad) {
|
|
347
|
+
var el = document.getElementById('upgradeMsg');
|
|
348
|
+
if (!el) return;
|
|
349
|
+
el.innerHTML = html;
|
|
350
|
+
el.className = bad ? 'error' : 'hint';
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// After a successful upgrade the server restarts: wait for it to answer
|
|
354
|
+
// again on the new version, then reload so the page runs the new code.
|
|
355
|
+
function waitForRestart(target, tries) {
|
|
356
|
+
if (tries > 90) { upgradeMsg('The server hasn' + String.fromCharCode(8217) + 't come back yet. Reload this page in a minute.', true); return; }
|
|
357
|
+
setTimeout(function () {
|
|
358
|
+
fetch('/config/settings', { cache: 'no-store' })
|
|
359
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
360
|
+
.then(function (s) {
|
|
361
|
+
if (s && s.version === target) location.reload();
|
|
362
|
+
else waitForRestart(target, tries + 1);
|
|
363
|
+
})
|
|
364
|
+
.catch(function () { waitForRestart(target, tries + 1); });
|
|
365
|
+
}, 2000);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
document.getElementById('settingsContent').addEventListener('click', function (ev) {
|
|
369
|
+
var btn = ev.target.closest('#upgradeBtn');
|
|
370
|
+
if (!btn) return;
|
|
371
|
+
if (btn.getAttribute('data-armed') !== '1') {
|
|
372
|
+
btn.setAttribute('data-armed', '1');
|
|
373
|
+
btn.textContent = 'Click again to upgrade';
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
btn.disabled = true;
|
|
377
|
+
btn.textContent = 'Upgrading' + String.fromCharCode(8230);
|
|
378
|
+
upgradeMsg('Installing the new version. This takes a minute or two; keep this page open.', false);
|
|
379
|
+
fetch('/config/upgrade', { method: 'POST' })
|
|
380
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
381
|
+
.then(function (r) {
|
|
382
|
+
if (!r.ok) throw new Error(r.body.error || 'upgrade failed');
|
|
383
|
+
var o = r.body.outcome;
|
|
384
|
+
var changed = o.files.filter(function (f) { return f.status === 'updated'; }).map(function (f) { return f.file; });
|
|
385
|
+
var kept = o.files.filter(function (f) { return f.status === 'kept'; }).map(function (f) { return f.file; });
|
|
386
|
+
var deps = o.dependencies.map(function (d) { return d.name + ' ' + d.from + ' ' + String.fromCharCode(8594) + ' ' + d.to; });
|
|
387
|
+
var target = (o.dependencies.filter(function (d) { return d.name === 'loopengine'; })[0] || {}).to;
|
|
388
|
+
var summary = (deps.length ? 'Installed ' + escapeHtml(deps.join(', ')) + '. ' : '') +
|
|
389
|
+
(changed.length ? 'Updated ' + escapeHtml(changed.join(', ')) + '. ' : '') +
|
|
390
|
+
(kept.length ? 'Left as you edited them: ' + escapeHtml(kept.join(', ')) + '. ' : '');
|
|
391
|
+
btn.textContent = 'Upgraded';
|
|
392
|
+
if (r.body.restarting && target) {
|
|
393
|
+
upgradeMsg(summary + 'Restarting' + String.fromCharCode(8230), false);
|
|
394
|
+
waitForRestart(target.replace(/[^0-9.]/g, ''), 0);
|
|
395
|
+
} else {
|
|
396
|
+
upgradeMsg(summary + 'Restart the server to finish.', false);
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
.catch(function (err) {
|
|
400
|
+
btn.disabled = false;
|
|
401
|
+
btn.removeAttribute('data-armed');
|
|
402
|
+
btn.textContent = 'Try again';
|
|
403
|
+
upgradeMsg(escapeHtml(err.message) + ' Nothing was changed.', true);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
308
407
|
function loadSettings() {
|
|
309
408
|
var content = document.getElementById('settingsContent');
|
|
310
409
|
fetch('/config/settings')
|
|
@@ -312,6 +411,7 @@ ${consoleShellScript}
|
|
|
312
411
|
.then(function (d) {
|
|
313
412
|
content.innerHTML = renderSettings(d);
|
|
314
413
|
loadedSections.settings = true;
|
|
414
|
+
loadUpgrade();
|
|
315
415
|
})
|
|
316
416
|
.catch(function (err) {
|
|
317
417
|
content.innerHTML = '<p class="error">Could not load settings: ' + escapeHtml(err.message) + '</p>';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export declare class UpgradeError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export interface UpgradeStatus {
|
|
4
|
+
current: string;
|
|
5
|
+
/** Latest loopengine on npm; null when npm couldn't be reached. */
|
|
6
|
+
latest: string | null;
|
|
7
|
+
updateAvailable: boolean;
|
|
8
|
+
/** The create-loopengine version this project was scaffolded or last
|
|
9
|
+
* upgraded from (.create-loopengine.json); null when the project
|
|
10
|
+
* wasn't made by create-loopengine, which the button can't upgrade. */
|
|
11
|
+
templateVersion: string | null;
|
|
12
|
+
/** Why the button is off, when it is. */
|
|
13
|
+
unavailableReason?: string;
|
|
14
|
+
running: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface UpgradeOutcome {
|
|
17
|
+
fromVersion: string;
|
|
18
|
+
toVersion: string;
|
|
19
|
+
files: {
|
|
20
|
+
file: string;
|
|
21
|
+
status: string;
|
|
22
|
+
}[];
|
|
23
|
+
dependencies: {
|
|
24
|
+
name: string;
|
|
25
|
+
from: string;
|
|
26
|
+
to: string;
|
|
27
|
+
}[];
|
|
28
|
+
}
|
|
29
|
+
type Runner = (cmd: string, args: string[], cwd: string, timeoutMs: number) => Promise<string>;
|
|
30
|
+
/** Tests replace npm/npx with a fake. */
|
|
31
|
+
export declare function setUpgradeRunner(next: Runner | undefined): void;
|
|
32
|
+
export declare function describeUpgrade(projectDir?: string): Promise<UpgradeStatus>;
|
|
33
|
+
/**
|
|
34
|
+
* Runs the upgrade. `busy` is checked first: upgrading under a running
|
|
35
|
+
* chat turn or scheduled run would restart the server mid-reply. Resolves
|
|
36
|
+
* with what changed; throws UpgradeError (with everything restored) when
|
|
37
|
+
* it couldn't finish.
|
|
38
|
+
*/
|
|
39
|
+
export declare function runUpgrade(options: {
|
|
40
|
+
busy: () => boolean;
|
|
41
|
+
projectDir?: string;
|
|
42
|
+
}): Promise<UpgradeOutcome>;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Backs the console's Upgrade button (Settings section): updates a
|
|
2
|
+
// create-loopengine project in place to the latest loopengine and
|
|
3
|
+
// template, the same as running `npx create-loopengine@latest upgrade`
|
|
4
|
+
// by hand, then the server restarts itself on the new code.
|
|
5
|
+
//
|
|
6
|
+
// Only template-owned files (adapters/, agent-registry.ts, tsconfig.json,
|
|
7
|
+
// .gitignore) and package.json change. Agents, abilities, conversations
|
|
8
|
+
// (.sessions/), approvals (.checkpoints/), schedules (.scheduler/) and
|
|
9
|
+
// every .env are never touched. A template file the project edited is
|
|
10
|
+
// left exactly as it was (`--keep-edited`), so an unattended upgrade can
|
|
11
|
+
// never leave conflict markers that stop the server from starting. The
|
|
12
|
+
// files it may change are backed up first and restored if it fails.
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { basename, dirname, join } from 'node:path';
|
|
16
|
+
import semver from 'semver';
|
|
17
|
+
import { loopengineVersion } from '../core/version.js';
|
|
18
|
+
/** Everything `create-loopengine upgrade` may rewrite. */
|
|
19
|
+
const UPGRADED_FILES = ['adapters/http.ts', 'adapters/cli.ts', 'agent-registry.ts', 'tsconfig.json', '.gitignore', 'package.json', 'package-lock.json', '.create-loopengine.json'];
|
|
20
|
+
const BACKUP_DIR = '.upgrade-backups';
|
|
21
|
+
const KEEP_BACKUPS = 3;
|
|
22
|
+
export class UpgradeError extends Error {
|
|
23
|
+
}
|
|
24
|
+
function realRun(cmd, args, cwd, timeoutMs) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, env: process.env }, (err, stdout, stderr) => {
|
|
27
|
+
if (err)
|
|
28
|
+
reject(Object.assign(err, { stdout: String(stdout), stderr: String(stderr) }));
|
|
29
|
+
else
|
|
30
|
+
resolve(String(stdout));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
let run = realRun;
|
|
35
|
+
/** Tests replace npm/npx with a fake. */
|
|
36
|
+
export function setUpgradeRunner(next) {
|
|
37
|
+
run = next ?? realRun;
|
|
38
|
+
latestCache = undefined;
|
|
39
|
+
}
|
|
40
|
+
let latestCache;
|
|
41
|
+
async function latestLoopengine(projectDir) {
|
|
42
|
+
if (latestCache && latestCache.until > Date.now())
|
|
43
|
+
return latestCache.value;
|
|
44
|
+
let value = null;
|
|
45
|
+
try {
|
|
46
|
+
value = (await run('npm', ['view', 'loopengine', 'version'], projectDir, 15_000)).trim() || null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
value = null;
|
|
50
|
+
}
|
|
51
|
+
latestCache = { value, until: Date.now() + (value ? 10 * 60_000 : 60_000) };
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function templateVersion(projectDir) {
|
|
55
|
+
const path = join(projectDir, '.create-loopengine.json');
|
|
56
|
+
if (!existsSync(path))
|
|
57
|
+
return null;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(path, 'utf8')).version ?? null;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
let running = false;
|
|
66
|
+
export async function describeUpgrade(projectDir = process.cwd()) {
|
|
67
|
+
const current = loopengineVersion();
|
|
68
|
+
const latest = await latestLoopengine(projectDir);
|
|
69
|
+
const template = templateVersion(projectDir);
|
|
70
|
+
const status = {
|
|
71
|
+
current,
|
|
72
|
+
latest,
|
|
73
|
+
updateAvailable: Boolean(latest && semver.valid(current) && semver.gt(latest, current)),
|
|
74
|
+
templateVersion: template,
|
|
75
|
+
running,
|
|
76
|
+
};
|
|
77
|
+
if (!template)
|
|
78
|
+
status.unavailableReason = 'This project has no .create-loopengine.json, so it was not made by create-loopengine; upgrade it by hand.';
|
|
79
|
+
else if (!latest)
|
|
80
|
+
status.unavailableReason = 'Could not reach npm to check for a newer version.';
|
|
81
|
+
return status;
|
|
82
|
+
}
|
|
83
|
+
function backup(projectDir) {
|
|
84
|
+
const dir = join(projectDir, BACKUP_DIR, new Date().toISOString().replace(/[:.]/g, '-'));
|
|
85
|
+
for (const file of UPGRADED_FILES) {
|
|
86
|
+
const from = join(projectDir, file);
|
|
87
|
+
if (!existsSync(from))
|
|
88
|
+
continue;
|
|
89
|
+
mkdirSync(dirname(join(dir, file)), { recursive: true });
|
|
90
|
+
copyFileSync(from, join(dir, file));
|
|
91
|
+
}
|
|
92
|
+
// Keep the newest few.
|
|
93
|
+
const all = readdirSync(join(projectDir, BACKUP_DIR)).filter((name) => name !== 'pending').sort();
|
|
94
|
+
for (const old of all.slice(0, Math.max(0, all.length - KEEP_BACKUPS)))
|
|
95
|
+
rmSync(join(projectDir, BACKUP_DIR, old), { recursive: true, force: true });
|
|
96
|
+
return dir;
|
|
97
|
+
}
|
|
98
|
+
function restore(projectDir, dir) {
|
|
99
|
+
for (const file of UPGRADED_FILES) {
|
|
100
|
+
const saved = join(dir, file);
|
|
101
|
+
if (existsSync(saved))
|
|
102
|
+
copyFileSync(saved, join(projectDir, file));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Runs the upgrade. `busy` is checked first: upgrading under a running
|
|
107
|
+
* chat turn or scheduled run would restart the server mid-reply. Resolves
|
|
108
|
+
* with what changed; throws UpgradeError (with everything restored) when
|
|
109
|
+
* it couldn't finish.
|
|
110
|
+
*/
|
|
111
|
+
export async function runUpgrade(options) {
|
|
112
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
113
|
+
if (running)
|
|
114
|
+
throw new UpgradeError('An upgrade is already running.');
|
|
115
|
+
if (options.busy())
|
|
116
|
+
throw new UpgradeError('An agent is replying or a scheduled task is running; try again when it finishes.');
|
|
117
|
+
if (!templateVersion(projectDir))
|
|
118
|
+
throw new UpgradeError('This project was not made by create-loopengine, so it can only be upgraded by hand.');
|
|
119
|
+
running = true;
|
|
120
|
+
const saved = backup(projectDir);
|
|
121
|
+
try {
|
|
122
|
+
let stdout;
|
|
123
|
+
try {
|
|
124
|
+
stdout = await run('npx', ['-y', 'create-loopengine@latest', 'upgrade', '--json', '--keep-edited'], projectDir, 10 * 60_000);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
stdout = err.stdout ?? '';
|
|
128
|
+
if (!stdout.trim())
|
|
129
|
+
throw new UpgradeError(`The upgrade could not run: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`);
|
|
130
|
+
}
|
|
131
|
+
const line = stdout.trim().split('\n').pop() ?? '';
|
|
132
|
+
let result;
|
|
133
|
+
try {
|
|
134
|
+
result = JSON.parse(line);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new UpgradeError('The upgrade returned something unexpected; nothing was changed.');
|
|
138
|
+
}
|
|
139
|
+
if (result.error)
|
|
140
|
+
throw new UpgradeError(result.error);
|
|
141
|
+
if (result.npmInstall && !result.npmInstall.ok)
|
|
142
|
+
throw new UpgradeError(`Installing the new packages failed: ${result.npmInstall.error ?? 'unknown error'}`);
|
|
143
|
+
// package.json may already allow the latest version while an older
|
|
144
|
+
// one stays installed (the lockfile keeps it); install it outright.
|
|
145
|
+
const latest = await latestLoopengine(projectDir);
|
|
146
|
+
const current = loopengineVersion();
|
|
147
|
+
if (latest && semver.valid(current) && semver.gt(latest, current) && !result.dependencies.some((d) => d.name === 'loopengine')) {
|
|
148
|
+
try {
|
|
149
|
+
await run('npm', ['install', '--no-audit', '--no-fund', `loopengine@^${latest}`], projectDir, 10 * 60_000);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw new UpgradeError(`Installing loopengine ${latest} failed: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`);
|
|
153
|
+
}
|
|
154
|
+
result.dependencies.push({ name: 'loopengine', from: current, to: `^${latest}` });
|
|
155
|
+
}
|
|
156
|
+
// Lets the workspace's start script put the backup back if the new
|
|
157
|
+
// code can't start (see loopengine-cloud's entrypoint); removed by it
|
|
158
|
+
// once the new version has stayed up.
|
|
159
|
+
writeFileSync(join(projectDir, BACKUP_DIR, 'pending'), basename(saved) + '\n');
|
|
160
|
+
latestCache = undefined;
|
|
161
|
+
return { fromVersion: result.fromVersion, toVersion: result.toVersion, files: result.files, dependencies: result.dependencies };
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
restore(projectDir, saved);
|
|
165
|
+
// package.json is back; put node_modules back in line with it.
|
|
166
|
+
await run('npm', ['install', '--no-audit', '--no-fund'], projectDir, 10 * 60_000).catch(() => { });
|
|
167
|
+
throw err instanceof UpgradeError ? err : new UpgradeError(err instanceof Error ? err.message : String(err));
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
running = false;
|
|
171
|
+
}
|
|
172
|
+
}
|