ai-agentmanager 1.3.0 → 1.4.0
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/CHANGELOG.md +17 -0
- package/README.md +23 -5
- package/docs/distributed.md +2 -1
- package/docs/plugins.md +2 -1
- package/package.json +1 -1
- package/public/app.js +107 -4
- package/public/code.js +449 -0
- package/public/hub/hub.js +33 -5
- package/public/index.html +62 -6
- package/public/machine.js +114 -0
- package/public/menu.js +92 -0
- package/public/style.css +80 -0
- package/public/update.js +88 -0
- package/src/cleanup.js +71 -2
- package/src/cli.js +74 -12
- package/src/client.js +46 -6
- package/src/fleet.js +1 -0
- package/src/git.js +2 -2
- package/src/handoff.js +240 -0
- package/src/hub.js +50 -2
- package/src/hubactions.js +14 -1
- package/src/identity.js +41 -0
- package/src/manager.js +25 -17
- package/src/server.js +50 -9
- package/src/studio.js +255 -0
- package/src/update.js +160 -0
package/src/update.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Updating Agent Manager in place, then restarting so agents resume on the new version. Two kinds
|
|
2
|
+
// of install: a git checkout (cloned at a release tag, or `npm link`ed from one) moves to the
|
|
3
|
+
// newest release tag; an npm install runs `npm install -g <package>@<version>`. A checkout on a
|
|
4
|
+
// branch or with uncommitted changes is someone's working copy, so it is left alone.
|
|
5
|
+
import { execFile, spawn } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { paths } from './config.js';
|
|
10
|
+
import { latestRelease, newerThan } from './about.js';
|
|
11
|
+
|
|
12
|
+
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
const BIN = path.join(root, 'bin', 'agentmgr.js');
|
|
14
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
15
|
+
const CHECK_EVERY_MS = 12 * 3_600_000;
|
|
16
|
+
|
|
17
|
+
const run = (cmd, args, { cwd = root, timeoutMs = 120_000 } = {}) =>
|
|
18
|
+
new Promise((resolve, reject) => {
|
|
19
|
+
execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 8 << 20, env: process.env }, (e, stdout, stderr) => {
|
|
20
|
+
if (e) reject(Object.assign(new Error(`${cmd} ${args.join(' ')}: ${(stderr || e.message).trim().split('\n').pop()}`), { status: 500 }));
|
|
21
|
+
else resolve(stdout.trim());
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const pkg = (dir = root) => JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
26
|
+
|
|
27
|
+
/** The newest release tag (vX.Y.Z, no pre-releases) among `tags`, or null. */
|
|
28
|
+
export function newestTag(tags) {
|
|
29
|
+
return (
|
|
30
|
+
tags
|
|
31
|
+
.map((t) => t.trim())
|
|
32
|
+
.filter((t) => /^v\d+\.\d+\.\d+$/.test(t))
|
|
33
|
+
.sort((a, b) => (newerThan(a.slice(1), b.slice(1)) ? -1 : newerThan(b.slice(1), a.slice(1)) ? 1 : 0))[0] ?? null
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How this copy was installed: 'git' (a checkout, worktrees included) or 'npm'. */
|
|
38
|
+
export const installKind = (dir = root) => (fs.existsSync(path.join(dir, '.git')) ? 'git' : 'npm');
|
|
39
|
+
|
|
40
|
+
/** Why a git checkout can't be moved to a release tag, or null when it can. */
|
|
41
|
+
export async function gitBlocker(dir = root) {
|
|
42
|
+
const branch = await run('git', ['symbolic-ref', '-q', '--short', 'HEAD'], { cwd: dir }).catch(() => '');
|
|
43
|
+
if (branch) return `This copy is a git checkout on branch "${branch}" (someone's working copy); update it with git, or run a release from a checkout at a release tag`;
|
|
44
|
+
const changed = await run('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: dir });
|
|
45
|
+
if (changed) return 'This checkout has uncommitted changes; commit or discard them first';
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What is installed, the newest release, and whether this copy can update itself to it. */
|
|
50
|
+
export async function checkUpdate({ dir = root, fetchTags = true } = {}) {
|
|
51
|
+
const kind = installKind(dir);
|
|
52
|
+
const current = pkg(dir).version;
|
|
53
|
+
if (kind === 'git') {
|
|
54
|
+
if (fetchTags) await run('git', ['fetch', '--tags', '--quiet', 'origin'], { cwd: dir, timeoutMs: 60_000 });
|
|
55
|
+
const tag = newestTag((await run('git', ['tag', '-l', 'v*'], { cwd: dir })).split('\n'));
|
|
56
|
+
const latest = tag ? tag.slice(1) : current;
|
|
57
|
+
const newer = newerThan(latest, current);
|
|
58
|
+
return { kind, current, latest, newer, blocker: newer ? await gitBlocker(dir) : null, checkedAt: new Date().toISOString() };
|
|
59
|
+
}
|
|
60
|
+
const r = await latestRelease();
|
|
61
|
+
return { kind, current, latest: r.latest, newer: r.newer, blocker: null, checkedAt: new Date().toISOString() };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Put the newest release in place (no restart). Returns { from, to } or { upToDate }. */
|
|
65
|
+
export async function installUpdate({ dir = root, log = () => {} } = {}) {
|
|
66
|
+
const info = await checkUpdate({ dir });
|
|
67
|
+
if (!info.newer) return { upToDate: true, current: info.current };
|
|
68
|
+
if (info.blocker) throw err(409, info.blocker);
|
|
69
|
+
if (info.kind === 'git') {
|
|
70
|
+
const lockBefore = await run('git', ['rev-parse', 'HEAD:package-lock.json'], { cwd: dir }).catch(() => '');
|
|
71
|
+
log(`checking out v${info.latest}`);
|
|
72
|
+
await run('git', ['checkout', '--quiet', '--detach', `v${info.latest}`], { cwd: dir });
|
|
73
|
+
const lockAfter = await run('git', ['rev-parse', 'HEAD:package-lock.json'], { cwd: dir }).catch(() => '');
|
|
74
|
+
if (lockAfter !== lockBefore || !fs.existsSync(path.join(dir, 'node_modules'))) {
|
|
75
|
+
log('installing dependencies');
|
|
76
|
+
await run('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir, timeoutMs: 600_000 });
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
const name = pkg(dir).name;
|
|
80
|
+
log(`npm install -g ${name}@${info.latest}`);
|
|
81
|
+
await run('npm', ['install', '-g', `${name}@${info.latest}`], { timeoutMs: 600_000 }).catch((e) => {
|
|
82
|
+
throw err(500, `${e.message}${/EACCES|permission/i.test(e.message) ? ' (npm needs write access to its global folder)' : ''}`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { from: info.current, to: info.latest, kind: info.kind };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Restart the daemon from a separate process, so it outlives this one (`agentmgr restart`). */
|
|
89
|
+
export function restartDetached() {
|
|
90
|
+
const out = fs.openSync(paths.log, 'a');
|
|
91
|
+
const child = spawn(process.execPath, [BIN, 'restart'], { detached: true, stdio: ['ignore', out, out], env: process.env });
|
|
92
|
+
child.unref();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The daemon's side: a check twice a day (config "updates": { "check": false } turns it off). */
|
|
96
|
+
export class Updates {
|
|
97
|
+
constructor({ config = {}, log = (m) => console.log(`[agentmgr] ${m}`), check = checkUpdate } = {}) {
|
|
98
|
+
this.config = config;
|
|
99
|
+
this.log = log;
|
|
100
|
+
this.check = check;
|
|
101
|
+
this.last = null;
|
|
102
|
+
this.busy = null;
|
|
103
|
+
this.schedule();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get enabled() {
|
|
107
|
+
return this.config.updates?.check !== false && process.env.AGENTMGR_UPDATE_CHECK !== '0';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Start or stop the periodic check to match the setting. */
|
|
111
|
+
schedule() {
|
|
112
|
+
this.close();
|
|
113
|
+
if (!this.enabled) return;
|
|
114
|
+
this.first = setTimeout(() => this.refresh().catch(() => {}), 30_000);
|
|
115
|
+
this.timer = setInterval(() => this.refresh().catch(() => {}), CHECK_EVERY_MS);
|
|
116
|
+
this.first.unref?.();
|
|
117
|
+
this.timer.unref?.();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setCheck(on) {
|
|
121
|
+
this.config.updates = { ...this.config.updates, check: Boolean(on) };
|
|
122
|
+
this.schedule();
|
|
123
|
+
return this.config.updates;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
close() {
|
|
127
|
+
clearTimeout(this.first);
|
|
128
|
+
clearInterval(this.timer);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async refresh() {
|
|
132
|
+
try {
|
|
133
|
+
this.last = await this.check();
|
|
134
|
+
} catch (e) {
|
|
135
|
+
this.last = { ...(this.last ?? {}), error: e.message, checkedAt: new Date().toISOString() };
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
return this.last;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
status() {
|
|
142
|
+
return { check: this.enabled, updating: Boolean(this.busy), ...(this.last ?? {}) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Install the newest release, then restart onto it. Answers before the restart begins. */
|
|
146
|
+
async apply({ restart = restartDetached } = {}) {
|
|
147
|
+
if (this.busy) throw err(409, 'An update is already running');
|
|
148
|
+
this.busy = installUpdate({ log: (m) => this.log(`update: ${m}`) });
|
|
149
|
+
try {
|
|
150
|
+
const r = await this.busy;
|
|
151
|
+
if (r.upToDate) return r;
|
|
152
|
+
this.log(`update: ${r.from} → ${r.to} installed; restarting`);
|
|
153
|
+
this.last = { ...(this.last ?? {}), current: r.to, newer: false };
|
|
154
|
+
setTimeout(restart, 200);
|
|
155
|
+
return { ...r, restarting: true };
|
|
156
|
+
} finally {
|
|
157
|
+
this.busy = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|