ai-agentmanager 1.2.1 → 1.3.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 +15 -0
- package/README.md +94 -3
- package/docs/plugins.md +4 -1
- package/package.json +1 -1
- package/public/app.js +65 -3
- package/public/index.html +133 -1
- package/public/land.js +149 -0
- package/public/playbooks.js +132 -0
- package/public/review.js +368 -0
- package/public/setup.js +67 -0
- package/public/spend.js +82 -0
- package/public/style.css +104 -0
- package/public/triggers.js +85 -0
- package/src/assistant.js +13 -3
- package/src/budgets.js +166 -0
- package/src/cli.js +292 -2
- package/src/engines/claude.js +6 -4
- package/src/git.js +4 -4
- package/src/landing.js +212 -0
- package/src/manager.js +98 -67
- package/src/memory.js +106 -0
- package/src/notify.js +3 -2
- package/src/playbooks.js +178 -0
- package/src/review.js +209 -0
- package/src/server.js +85 -3
- package/src/stats.js +107 -0
- package/src/tracker.js +61 -8
- package/src/triggers.js +258 -0
package/src/review.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Review an agent's changes: the diff against where its branch started, line comments sent
|
|
2
|
+
// back to the agent as one message, and commit / push / open a pull request from the UI.
|
|
3
|
+
import * as git from './git.js';
|
|
4
|
+
import { NO_REPO } from './manager.js';
|
|
5
|
+
import { ghBin } from './tracker.js';
|
|
6
|
+
|
|
7
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
8
|
+
const MAX_COMMENTS = 100;
|
|
9
|
+
|
|
10
|
+
/** "origin/main @ 1a2b3c4d5" -> { ref: 'origin/main', sha: '1a2b3c4d5' } */
|
|
11
|
+
export function parseBaseRef(baseRef) {
|
|
12
|
+
const m = /^(.*) @ ([0-9a-f]{4,40})$/.exec(baseRef ?? '');
|
|
13
|
+
return m ? { ref: m[1], sha: m[2] } : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The branch a pull request should target, from the ref the agent branched from. */
|
|
17
|
+
export function prBase(ref) {
|
|
18
|
+
if (!ref || ref === 'HEAD' || /^[0-9a-f]{7,40}$/.test(ref)) return null;
|
|
19
|
+
return ref.replace(/^origin\//, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One message for the agent with every comment, its place and the line it is about. */
|
|
23
|
+
export function commentsMessage(comments, note = '') {
|
|
24
|
+
const lines = ['Review of your changes. Please address each comment, then say what you changed.', ''];
|
|
25
|
+
comments.forEach((c, i) => {
|
|
26
|
+
const where = c.file ? `${c.file}${c.line ? `:${c.line}` : ''}` : 'General';
|
|
27
|
+
lines.push(`${i + 1}. ${where}${c.side === 'old' ? ' (removed line)' : ''}`);
|
|
28
|
+
if (c.code) lines.push(` > ${String(c.code).trim().slice(0, 200)}`);
|
|
29
|
+
lines.push(` ${String(c.text).trim().replace(/\n/g, '\n ')}`);
|
|
30
|
+
});
|
|
31
|
+
if (note.trim()) lines.push('', note.trim());
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Per-file status from `git diff --name-status` plus untracked files. */
|
|
36
|
+
function fileStatuses(nameStatus, untracked) {
|
|
37
|
+
const out = {};
|
|
38
|
+
const kinds = { A: 'added', D: 'deleted', M: 'modified', T: 'modified' };
|
|
39
|
+
for (const line of nameStatus.split('\n').filter(Boolean)) {
|
|
40
|
+
const [code, ...paths] = line.split('\t');
|
|
41
|
+
out[paths.at(-1)] = kinds[code[0]] ?? (code[0] === 'R' ? 'renamed' : 'modified');
|
|
42
|
+
}
|
|
43
|
+
for (const f of untracked) out[f] = 'added';
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function addedLines(diff, file) {
|
|
48
|
+
const start = diff.indexOf(`diff --git a/${file} b/${file}\n`);
|
|
49
|
+
if (start < 0) return null;
|
|
50
|
+
const end = diff.indexOf('\ndiff --git ', start + 1);
|
|
51
|
+
const part = diff.slice(start, end < 0 ? undefined : end);
|
|
52
|
+
if (/^Binary files /m.test(part)) return null;
|
|
53
|
+
return part.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class Review {
|
|
57
|
+
constructor({ manager }) {
|
|
58
|
+
this.manager = manager;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
agentWithRepo(ref) {
|
|
62
|
+
const agent = this.manager.mustGet(ref);
|
|
63
|
+
if (!agent.workdir || NO_REPO.has(agent.kind)) {
|
|
64
|
+
throw err(400, 'This agent has no repository to review');
|
|
65
|
+
}
|
|
66
|
+
return agent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where the diff starts: the merge base with the branch it came from, else the recorded commit. */
|
|
70
|
+
async base(agent) {
|
|
71
|
+
const b = parseBaseRef(agent.baseRef);
|
|
72
|
+
const opts = { cwd: agent.workdir, timeout: 30_000 };
|
|
73
|
+
if (b?.ref && b.ref !== 'HEAD') {
|
|
74
|
+
const mb = await git.run('git', ['merge-base', 'HEAD', b.ref], opts).catch(() => null);
|
|
75
|
+
if (mb) return { ref: b.ref, sha: mb };
|
|
76
|
+
}
|
|
77
|
+
if (b?.sha) return { ref: b.ref, sha: b.sha };
|
|
78
|
+
// No record (imported folders): the remote default branch, if there is one.
|
|
79
|
+
const r = await git.resolveBase(agent.workdir).catch(() => null);
|
|
80
|
+
if (r && r.ref !== 'HEAD') {
|
|
81
|
+
const mb = await git.run('git', ['merge-base', 'HEAD', r.ref], opts).catch(() => null);
|
|
82
|
+
if (mb) return { ref: r.ref, sha: mb };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async get(ref) {
|
|
88
|
+
const agent = this.agentWithRepo(ref);
|
|
89
|
+
const dir = agent.workdir;
|
|
90
|
+
if (!(await git.isGitRepo(dir))) throw err(400, 'The agent folder is not a git repository');
|
|
91
|
+
const opts = { cwd: dir, timeout: 30_000 };
|
|
92
|
+
const safe = (p) => p.catch(() => '');
|
|
93
|
+
const base = await this.base(agent);
|
|
94
|
+
const branch = await safe(git.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], opts));
|
|
95
|
+
const from = base?.sha ?? 'HEAD';
|
|
96
|
+
const [nameStatus, numstat, untrackedOut, log, status, upstream] = await Promise.all([
|
|
97
|
+
safe(git.run('git', ['diff', '--name-status', from], opts)),
|
|
98
|
+
safe(git.run('git', ['diff', '--numstat', from], opts)),
|
|
99
|
+
safe(git.run('git', ['ls-files', '--others', '--exclude-standard'], opts)),
|
|
100
|
+
base ? safe(git.run('git', ['log', '--format=%h%x09%s%x09%cI', '-n', '50', `${base.sha}..HEAD`], opts)) : '',
|
|
101
|
+
safe(git.run('git', ['status', '--porcelain'], opts)),
|
|
102
|
+
safe(git.run('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], opts)),
|
|
103
|
+
]);
|
|
104
|
+
const untracked = untrackedOut.split('\n').filter(Boolean);
|
|
105
|
+
const statuses = fileStatuses(nameStatus, untracked);
|
|
106
|
+
const counts = {};
|
|
107
|
+
for (const line of numstat.split('\n').filter(Boolean)) {
|
|
108
|
+
const [a, d, ...p] = line.split('\t');
|
|
109
|
+
counts[p.join('\t').replace(/^.*=> /, '').replace(/[{}]/g, '')] = { added: a === '-' ? null : Number(a), removed: d === '-' ? null : Number(d) };
|
|
110
|
+
}
|
|
111
|
+
const diff = await git.fullDiff(dir, from);
|
|
112
|
+
const files = Object.entries(statuses)
|
|
113
|
+
.map(([path, st]) => ({ path, status: st, ...(counts[path] ?? { added: null, removed: 0 }), untracked: untracked.includes(path) }))
|
|
114
|
+
.sort((x, y) => (x.path < y.path ? -1 : 1));
|
|
115
|
+
// New files git doesn't count yet: their added lines, from the diff.
|
|
116
|
+
for (const f of files.filter((x) => x.untracked && x.added == null)) f.added = addedLines(diff, f.path);
|
|
117
|
+
let ahead = 0;
|
|
118
|
+
if (upstream) ahead = Number(await safe(git.run('git', ['rev-list', '--count', `${upstream}..HEAD`], opts))) || 0;
|
|
119
|
+
const rt = this.manager.runtimes.get(agent.id);
|
|
120
|
+
return {
|
|
121
|
+
id: agent.id,
|
|
122
|
+
name: agent.name,
|
|
123
|
+
branch,
|
|
124
|
+
base,
|
|
125
|
+
prBase: prBase(base?.ref),
|
|
126
|
+
commits: log.split('\n').filter(Boolean).map((l) => {
|
|
127
|
+
const [sha, subject, at] = l.split('\t');
|
|
128
|
+
return { sha, subject, at };
|
|
129
|
+
}),
|
|
130
|
+
files,
|
|
131
|
+
uncommitted: status ? status.split('\n').length : 0,
|
|
132
|
+
upstream: upstream || null,
|
|
133
|
+
unpushed: upstream ? ahead : null,
|
|
134
|
+
hasRemote: await git.hasRemote(dir),
|
|
135
|
+
prs: Object.values(agent.prs ?? {}),
|
|
136
|
+
busy: Boolean(rt?.pty && rt.busy),
|
|
137
|
+
diff,
|
|
138
|
+
truncated: diff.endsWith('… (diff truncated)'),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Send the comments to the agent as one message (typed in when it is idle). */
|
|
143
|
+
comment(ref, { comments = [], note = '' } = {}) {
|
|
144
|
+
const agent = this.agentWithRepo(ref);
|
|
145
|
+
const list = (Array.isArray(comments) ? comments : []).filter((c) => String(c?.text ?? '').trim()).slice(0, MAX_COMMENTS);
|
|
146
|
+
if (!list.length && !String(note).trim()) throw err(400, 'Add a comment first');
|
|
147
|
+
const text = commentsMessage(list, String(note));
|
|
148
|
+
this.manager.tell(agent.id, text, { source: 'review' });
|
|
149
|
+
agent.reviewsSent = [...(agent.reviewsSent ?? []), { at: new Date().toISOString(), comments: list.length }].slice(-20);
|
|
150
|
+
this.manager.store.save();
|
|
151
|
+
return { sent: list.length, text };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Commit everything in the worktree (new files included) with the person's git identity. */
|
|
155
|
+
async commit(ref, { message } = {}) {
|
|
156
|
+
const agent = this.agentWithRepo(ref);
|
|
157
|
+
message = String(message ?? '').trim();
|
|
158
|
+
if (!message) throw err(400, 'A commit message is required');
|
|
159
|
+
this.manager.plugins.check('agent.commit', { agent: this.manager.auditView(agent), message });
|
|
160
|
+
const opts = { cwd: agent.workdir, timeout: 60_000 };
|
|
161
|
+
const status = await git.run('git', ['status', '--porcelain'], opts);
|
|
162
|
+
if (!status.trim()) throw err(409, 'Nothing to commit');
|
|
163
|
+
await git.run('git', ['add', '-A'], opts);
|
|
164
|
+
await git.run('git', ['commit', '-m', message], opts);
|
|
165
|
+
const sha = await git.run('git', ['rev-parse', '--short', 'HEAD'], opts);
|
|
166
|
+
this.manager.plugins.emit('agent.commit', { agent: this.manager.auditView(agent), message, sha });
|
|
167
|
+
this.manager.refresh(agent.id).catch(() => {});
|
|
168
|
+
return { sha, files: status.split('\n').length };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Push the branch and open a pull request (or return the one already open). */
|
|
172
|
+
async pr(ref, { title = '', body = '', draft = false } = {}) {
|
|
173
|
+
const agent = this.agentWithRepo(ref);
|
|
174
|
+
const dir = agent.workdir;
|
|
175
|
+
this.manager.plugins.check('agent.pr', { agent: this.manager.auditView(agent), title: String(title), draft: Boolean(draft) });
|
|
176
|
+
const opts = { cwd: dir, timeout: 120_000 };
|
|
177
|
+
if (!(await git.hasRemote(dir))) throw err(409, 'The repository has no "origin" remote to push to');
|
|
178
|
+
const branch = await git.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], opts);
|
|
179
|
+
if (!branch || branch === 'HEAD') throw err(409, 'The worktree is not on a branch');
|
|
180
|
+
const dirty = await git.run('git', ['status', '--porcelain'], opts);
|
|
181
|
+
if (dirty.trim()) throw err(409, 'There are uncommitted changes: commit them first');
|
|
182
|
+
const base = await this.base(agent);
|
|
183
|
+
const target = prBase(base?.ref);
|
|
184
|
+
if (target && branch === target) throw err(409, `The worktree is on ${branch} itself; a pull request needs its own branch`);
|
|
185
|
+
await git.run('git', ['push', '-u', 'origin', `HEAD:refs/heads/${branch}`], opts);
|
|
186
|
+
const open = Object.values(agent.prs ?? {}).find((p) => p.state === 'OPEN');
|
|
187
|
+
if (open) {
|
|
188
|
+
this.manager.refresh(agent.id, { withPrs: true }).catch(() => {});
|
|
189
|
+
return { url: open.url, pushed: true, existing: true };
|
|
190
|
+
}
|
|
191
|
+
const args = ['pr', 'create', '--head', branch];
|
|
192
|
+
if (target) args.push('--base', target);
|
|
193
|
+
if (String(title).trim()) args.push('--title', String(title).trim(), '--body', String(body ?? ''));
|
|
194
|
+
else args.push('--fill');
|
|
195
|
+
if (draft) args.push('--draft');
|
|
196
|
+
let out;
|
|
197
|
+
try {
|
|
198
|
+
out = await git.run(ghBin(), args, opts);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
const m = /https:\/\/\S+\/pull\/\d+/.exec(e.message);
|
|
201
|
+
if (m && /already exists/i.test(e.message)) out = m[0];
|
|
202
|
+
else throw err(502, `Pushed, but opening the pull request failed: ${e.message.replace(/^gh .*? failed: /, '')}`);
|
|
203
|
+
}
|
|
204
|
+
const url = /https:\/\/\S+\/pull\/\d+/.exec(out)?.[0];
|
|
205
|
+
if (url) this.manager.addLink(agent.id, { type: 'pr', value: url });
|
|
206
|
+
this.manager.plugins.emit('agent.pr', { agent: this.manager.auditView(agent), url });
|
|
207
|
+
return { url: url ?? null, pushed: true, existing: false };
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/server.js
CHANGED
|
@@ -10,9 +10,17 @@ import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.
|
|
|
10
10
|
import { AgentManager, shellNumber } from './manager.js';
|
|
11
11
|
import { Backlog } from './backlog.js';
|
|
12
12
|
import { Races } from './races.js';
|
|
13
|
+
import { Review } from './review.js';
|
|
13
14
|
import { Teams } from './teams.js';
|
|
14
15
|
import { Assistant } from './assistant.js';
|
|
15
16
|
import { Cleanup } from './cleanup.js';
|
|
17
|
+
import { Landing } from './landing.js';
|
|
18
|
+
import { Budgets } from './budgets.js';
|
|
19
|
+
import { stats } from './stats.js';
|
|
20
|
+
import { Playbooks } from './playbooks.js';
|
|
21
|
+
import { Memory } from './memory.js';
|
|
22
|
+
import { doctor } from './doctor.js';
|
|
23
|
+
import { Triggers, tokenMatches } from './triggers.js';
|
|
16
24
|
import { listEngines, registerEngine } from './engines/index.js';
|
|
17
25
|
import { Plugins, pluginSpecs } from './plugins.js';
|
|
18
26
|
import { HubLink, linkUrls } from './hublink.js';
|
|
@@ -47,7 +55,9 @@ function isAllowedOrigin(origin, port) {
|
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
|
|
50
|
-
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|shutdown$)/;
|
|
58
|
+
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|shutdown$)/;
|
|
59
|
+
const HOOK_PATH = '/api/hooks/agents';
|
|
60
|
+
const NO_REPO_KINDS = new Set(['general', 'assistant']);
|
|
51
61
|
|
|
52
62
|
export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
53
63
|
ensureHome();
|
|
@@ -58,8 +68,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
58
68
|
const manager = new AgentManager({ store, config, plugins });
|
|
59
69
|
const backlog = new Backlog({ manager, store, config });
|
|
60
70
|
const races = new Races({ manager, store });
|
|
71
|
+
const review = new Review({ manager });
|
|
61
72
|
const teams = new Teams({ manager, store, config });
|
|
62
73
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
74
|
+
const landing = new Landing({ manager, store, config });
|
|
63
75
|
const assistant = new Assistant({ manager, store, config });
|
|
64
76
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
65
77
|
const notifier = new Notifier({
|
|
@@ -71,6 +83,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
71
83
|
return `${phone ? phone.url : `http://127.0.0.1:${port}/`}#${id}/term`;
|
|
72
84
|
},
|
|
73
85
|
});
|
|
86
|
+
const triggers = new Triggers({ manager, store, config, notifier });
|
|
87
|
+
const budgets = new Budgets({ manager, store, config, notifier });
|
|
88
|
+
const playbooks = new Playbooks({ manager, store });
|
|
89
|
+
const memory = new Memory({ manager, config });
|
|
74
90
|
let remoteServer = null;
|
|
75
91
|
let remotePort = null;
|
|
76
92
|
// Live browser tabs: what a page does is saved on the agent's tab; popups become new tabs.
|
|
@@ -137,9 +153,13 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
137
153
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
138
154
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
139
155
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
156
|
+
// The webhook signs in with its token instead of a device session or the client header.
|
|
157
|
+
const bearer = /^Bearer (.+)$/i.exec(req.headers.authorization ?? '')?.[1];
|
|
158
|
+
const hook = req.method === 'POST' && req.url.split('?')[0] === HOOK_PATH && tokenMatches(req.headers['x-agentmgr-token'] ?? bearer, config.triggers?.webhook?.enabled ? config.triggers.webhook.token : null);
|
|
140
159
|
const why = denied(req);
|
|
141
|
-
if (why) return res.status(why === 'Sign in required' ? 401 : 403).json({ error: why });
|
|
142
|
-
if (
|
|
160
|
+
if (why && !(hook && why === 'Sign in required')) return res.status(why === 'Sign in required' ? 401 : 403).json({ error: why });
|
|
161
|
+
if (req.url.split('?')[0] === HOOK_PATH && !hook) return res.status(401).json({ error: 'Bad or missing token' });
|
|
162
|
+
if (!hook && !['GET', 'HEAD'].includes(req.method) && req.headers[CLIENT_HEADER] !== '1') {
|
|
143
163
|
return res.status(403).json({ error: `Missing ${CLIENT_HEADER} header` });
|
|
144
164
|
}
|
|
145
165
|
next();
|
|
@@ -403,6 +423,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
403
423
|
await manager.checkpoint(id);
|
|
404
424
|
return manager.get(id);
|
|
405
425
|
}));
|
|
426
|
+
api.get('/agents/:id/changes', wrap((req) => review.get(req.params.id)));
|
|
427
|
+
api.post('/agents/:id/changes/comments', wrap((req) => review.comment(req.params.id, req.body ?? {})));
|
|
428
|
+
api.post('/agents/:id/changes/commit', wrap((req) => review.commit(req.params.id, req.body ?? {})));
|
|
429
|
+
api.post('/agents/:id/changes/pr', wrap((req) => review.pr(req.params.id, req.body ?? {})));
|
|
406
430
|
api.post('/agents/:id/fork', wrap((req) => manager.fork(req.params.id, req.body ?? {})));
|
|
407
431
|
|
|
408
432
|
api.get('/races', wrap(() => races.list()));
|
|
@@ -423,6 +447,56 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
423
447
|
api.post('/assistant/start', wrap(() => assistant.start()));
|
|
424
448
|
api.post('/assistant/pause', wrap(() => assistant.pause()));
|
|
425
449
|
api.post('/assistant/check', wrap(() => assistant.check()));
|
|
450
|
+
api.get('/triggers', wrap(() => triggers.view()));
|
|
451
|
+
api.put('/triggers', wrap((req) => {
|
|
452
|
+
const { saved, token } = triggers.update(req.body ?? {});
|
|
453
|
+
saveConfig({ triggers: saved });
|
|
454
|
+
return { ...triggers.view(), ...(token ? { token } : {}) };
|
|
455
|
+
}));
|
|
456
|
+
api.post('/triggers/poll', wrap(async () => ({ ...(await triggers.poll()), ...triggers.view() })));
|
|
457
|
+
api.post('/hooks/agents', wrap((req) => triggers.hook(req.body ?? {})));
|
|
458
|
+
api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
|
|
459
|
+
api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
|
|
460
|
+
api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
|
|
461
|
+
// The first-run checklist: what is set up, and what this machine is missing.
|
|
462
|
+
api.get('/setup', wrap(async () => {
|
|
463
|
+
const checks = (await doctor().catch(() => [])).filter((c) => c.status === 'fail' || c.status === 'warn').filter((c) => c.name !== 'Daemon');
|
|
464
|
+
const agentsNow = store.list();
|
|
465
|
+
return {
|
|
466
|
+
checks,
|
|
467
|
+
steps: {
|
|
468
|
+
agent: agentsNow.some((a) => !NO_REPO_KINDS.has(a.kind)),
|
|
469
|
+
alerts: Boolean(loadNotify().enabled),
|
|
470
|
+
assistant: agentsNow.some((a) => a.kind === 'assistant') || Boolean(config.triggers?.github?.enabled || config.triggers?.webhook?.enabled),
|
|
471
|
+
remote: Boolean(remote.loadSettings().enabled),
|
|
472
|
+
budget: Boolean(config.budgets?.perAgentUsd || config.budgets?.perDayUsd),
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}));
|
|
476
|
+
api.get('/playbooks', wrap(() => playbooks.list()));
|
|
477
|
+
api.post('/playbooks', wrap((req) => playbooks.create(req.body ?? {})));
|
|
478
|
+
api.put('/playbooks/:id', wrap((req) => playbooks.update(req.params.id, req.body ?? {})));
|
|
479
|
+
api.delete('/playbooks/:id', wrap((req) => playbooks.remove(req.params.id)));
|
|
480
|
+
api.post('/playbooks/:id/run', wrap((req) => playbooks.run(req.params.id, { vars: req.body?.vars ?? {} })));
|
|
481
|
+
api.get('/stats', wrap((req) => {
|
|
482
|
+
const days = req.query.days === undefined ? 30 : Math.max(0, Math.min(3650, Math.floor(Number(req.query.days)) || 0));
|
|
483
|
+
return stats(store.list(), { days, projectOf: (a) => manager.projectInfo(a)?.name ?? null });
|
|
484
|
+
}));
|
|
485
|
+
api.get('/budgets', wrap(() => budgets.status()));
|
|
486
|
+
api.put('/budgets', wrap((req) => {
|
|
487
|
+
const next = budgets.update(req.body ?? {});
|
|
488
|
+
saveConfig({ budgets: next });
|
|
489
|
+
return budgets.status();
|
|
490
|
+
}));
|
|
491
|
+
api.get('/landing', wrap(() => landing.list()));
|
|
492
|
+
api.put('/landing', wrap((req) => {
|
|
493
|
+
const next = landing.update(req.body ?? {});
|
|
494
|
+
saveConfig({ landing: next });
|
|
495
|
+
return landing.list();
|
|
496
|
+
}));
|
|
497
|
+
api.post('/agents/:id/land/update', wrap((req) => landing.updateBranch(req.params.id, req.body ?? {})));
|
|
498
|
+
api.post('/agents/:id/land/fix', wrap((req) => landing.fix(req.params.id, req.body ?? {})));
|
|
499
|
+
api.post('/agents/:id/land/merge', wrap((req) => landing.merge(req.params.id, req.body ?? {})));
|
|
426
500
|
api.get('/cleanup', wrap(() => cleanup.status()));
|
|
427
501
|
api.put('/cleanup', wrap(async (req) => {
|
|
428
502
|
const next = cleanup.update(req.body ?? {});
|
|
@@ -775,6 +849,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
775
849
|
if (closing) return;
|
|
776
850
|
closing = true;
|
|
777
851
|
cleanup.close();
|
|
852
|
+
landing.close();
|
|
853
|
+
triggers.close();
|
|
854
|
+
budgets.close();
|
|
855
|
+
playbooks.close();
|
|
778
856
|
assistant.close();
|
|
779
857
|
console.log(`[agentmgr] shutting down (${reason}); closing agents so their sessions are saved`);
|
|
780
858
|
await manager.stopAll({ final: true });
|
|
@@ -812,6 +890,10 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
812
890
|
manager,
|
|
813
891
|
teams,
|
|
814
892
|
cleanup,
|
|
893
|
+
landing,
|
|
894
|
+
triggers,
|
|
895
|
+
budgets,
|
|
896
|
+
playbooks,
|
|
815
897
|
assistant,
|
|
816
898
|
backlog,
|
|
817
899
|
live,
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Results across agents for a period: how many started and from where, pull requests opened and
|
|
2
|
+
// merged, the merge rate, time to a first PR and to merge, cost per merged PR, and the same by
|
|
3
|
+
// engine and model and by project. Everything comes from what agentmgr already records.
|
|
4
|
+
import { costOf } from './budgets.js';
|
|
5
|
+
|
|
6
|
+
const HOUR = 3_600_000;
|
|
7
|
+
|
|
8
|
+
const median = (xs) => {
|
|
9
|
+
if (!xs.length) return null;
|
|
10
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
11
|
+
const m = Math.floor(s.length / 2);
|
|
12
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
13
|
+
};
|
|
14
|
+
const round = (n, d = 2) => (n == null ? null : Math.round(n * 10 ** d) / 10 ** d);
|
|
15
|
+
|
|
16
|
+
/** Where an agent came from. */
|
|
17
|
+
export function sourceOf(a) {
|
|
18
|
+
if (a.kind === 'assistant') return 'assistant session';
|
|
19
|
+
if (a.startedBy) return 'assistant';
|
|
20
|
+
if (a.trigger) return a.trigger.source === 'github' ? 'GitHub label' : 'webhook';
|
|
21
|
+
if (a.playbook) return 'playbook';
|
|
22
|
+
if (a.backlogItem) return 'backlog';
|
|
23
|
+
if (a.team) return 'team';
|
|
24
|
+
if (a.race) return 'race';
|
|
25
|
+
if (a.forkOf) return 'fork';
|
|
26
|
+
if (a.imported) return 'imported';
|
|
27
|
+
return 'you';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** PRs an agent opened or that were found on its branch (not ones it only mentioned). */
|
|
31
|
+
const ownPrs = (a) => Object.values(a.prs ?? {}).filter((p) => p.source !== 'mentioned');
|
|
32
|
+
|
|
33
|
+
function bucket() {
|
|
34
|
+
return { agents: 0, withPr: 0, merged: 0, closed: 0, open: 0, cost: 0, toPrH: [], toMergeH: [], fixes: 0 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function add(b, a) {
|
|
38
|
+
const prs = ownPrs(a);
|
|
39
|
+
const created = Date.parse(a.createdAt);
|
|
40
|
+
b.agents += 1;
|
|
41
|
+
b.cost += costOf(a);
|
|
42
|
+
if (prs.length) b.withPr += 1;
|
|
43
|
+
b.merged += prs.filter((p) => p.state === 'MERGED').length;
|
|
44
|
+
b.closed += prs.filter((p) => p.state === 'CLOSED').length;
|
|
45
|
+
b.open += prs.filter((p) => p.state === 'OPEN').length;
|
|
46
|
+
const opened = prs.map((p) => Date.parse(p.openedAt)).filter(Number.isFinite).sort((x, y) => x - y)[0];
|
|
47
|
+
if (opened && created && opened >= created) b.toPrH.push((opened - created) / HOUR);
|
|
48
|
+
for (const p of prs.filter((x) => x.state === 'MERGED' && x.mergedAt)) {
|
|
49
|
+
const m = Date.parse(p.mergedAt);
|
|
50
|
+
if (created && m >= created) b.toMergeH.push((m - created) / HOUR);
|
|
51
|
+
}
|
|
52
|
+
b.fixes += Object.keys(a.landing?.sent ?? {}).length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function finish(b) {
|
|
56
|
+
const decided = b.merged + b.closed;
|
|
57
|
+
return {
|
|
58
|
+
agents: b.agents,
|
|
59
|
+
withPr: b.withPr,
|
|
60
|
+
merged: b.merged,
|
|
61
|
+
closed: b.closed,
|
|
62
|
+
open: b.open,
|
|
63
|
+
mergeRate: decided ? round(b.merged / decided, 3) : null,
|
|
64
|
+
cost: round(b.cost),
|
|
65
|
+
costPerMerged: b.merged ? round(b.cost / b.merged) : null,
|
|
66
|
+
medianHoursToPr: round(median(b.toPrH), 1),
|
|
67
|
+
medianHoursToMerge: round(median(b.toMergeH), 1),
|
|
68
|
+
fixesSent: b.fixes,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Results for agents created in the last `days` days (0: all of them). */
|
|
73
|
+
export function stats(agents, { days = 30, now = Date.now(), projectOf = () => null } = {}) {
|
|
74
|
+
const since = days ? now - days * 24 * HOUR : 0;
|
|
75
|
+
const list = agents.filter((a) => a.kind !== 'general' && a.kind !== 'assistant' && Date.parse(a.createdAt) >= since);
|
|
76
|
+
const total = bucket();
|
|
77
|
+
const by = { source: new Map(), engine: new Map(), project: new Map() };
|
|
78
|
+
const put = (map, key, a) => {
|
|
79
|
+
if (!map.has(key)) map.set(key, bucket());
|
|
80
|
+
add(map.get(key), a);
|
|
81
|
+
};
|
|
82
|
+
for (const a of list) {
|
|
83
|
+
add(total, a);
|
|
84
|
+
put(by.source, sourceOf(a), a);
|
|
85
|
+
put(by.engine, `${a.engine ?? 'claude'}${a.model ? ` · ${a.model}` : ''}`, a);
|
|
86
|
+
put(by.project, projectOf(a) ?? 'none', a);
|
|
87
|
+
}
|
|
88
|
+
const rows = (map) =>
|
|
89
|
+
[...map.entries()]
|
|
90
|
+
.map(([key, b]) => ({ key, ...finish(b) }))
|
|
91
|
+
.sort((x, y) => y.agents - x.agents)
|
|
92
|
+
.slice(0, 20);
|
|
93
|
+
// Agents per day, for a small chart.
|
|
94
|
+
const perDay = {};
|
|
95
|
+
for (const a of list) {
|
|
96
|
+
const d = new Date(a.createdAt).toISOString().slice(0, 10);
|
|
97
|
+
perDay[d] = (perDay[d] ?? 0) + 1;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
days,
|
|
101
|
+
total: finish(total),
|
|
102
|
+
bySource: rows(by.source),
|
|
103
|
+
byEngine: rows(by.engine),
|
|
104
|
+
byProject: rows(by.project),
|
|
105
|
+
perDay: Object.entries(perDay).sort(([a], [b]) => (a < b ? -1 : 1)).map(([day, n]) => ({ day, agents: n })),
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/tracker.js
CHANGED
|
@@ -72,8 +72,9 @@ export function recentSessionFiles(cwd, sinceMs) {
|
|
|
72
72
|
const URL_TOKEN = /https?:\/\/[^\s"'<>`\\)\]]+/g;
|
|
73
73
|
const PR_PATH = /^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/;
|
|
74
74
|
const GH_ISSUE_PATH = /^https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)(?:[/?#]|$)/;
|
|
75
|
-
//
|
|
76
|
-
|
|
75
|
+
// Issue keys in links: Jira /browse/KEY-1 and ?selectedIssue=KEY-1 (boards, Polaris ideas),
|
|
76
|
+
// /issues/KEY-1, and Linear /issue/KEY-1.
|
|
77
|
+
const JIRA_KEY = /(?:\/browse\/|[?&]selectedIssue=|\/issues?\/)([A-Z][A-Z0-9_]+-\d+)\b/;
|
|
77
78
|
|
|
78
79
|
const trimUrl = (url) => url.replace(/[.,;:!?'"]+$/, '');
|
|
79
80
|
|
|
@@ -95,7 +96,7 @@ export function bugFromRef(ref, source) {
|
|
|
95
96
|
return { key: ref, url: null, source };
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
/** The
|
|
99
|
+
/** The issue key when the text starts with a Jira or Linear link ("https://x.atlassian.net/browse/ABC-1 fix it" → "ABC-1"). */
|
|
99
100
|
export function leadingJiraKey(text) {
|
|
100
101
|
const first = String(text ?? '').trimStart().match(/^<?(https?:\/\/\S+)/);
|
|
101
102
|
return first?.[1].match(JIRA_KEY)?.[1] ?? null;
|
|
@@ -218,17 +219,69 @@ export function scanTranscript(file, cursor, bugPatterns = []) {
|
|
|
218
219
|
return result;
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/** The gh CLI to run (AGENTMGR_GH_BIN points the tests at a fake one). */
|
|
223
|
+
export const ghBin = () => process.env.AGENTMGR_GH_BIN || 'gh';
|
|
224
|
+
|
|
225
|
+
const PR_FIELDS = 'number,title,url,state,isDraft,headRefOid,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup,createdAt,mergedAt';
|
|
226
|
+
const FAILED = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
|
|
227
|
+
const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
228
|
+
|
|
229
|
+
/** gh's statusCheckRollup -> { state: passed|failed|running|none, failed: [names], running, total } */
|
|
230
|
+
export function checkSummary(rollup) {
|
|
231
|
+
const list = Array.isArray(rollup) ? rollup : [];
|
|
232
|
+
const failed = [];
|
|
233
|
+
let running = 0;
|
|
234
|
+
for (const c of list) {
|
|
235
|
+
// Check runs have status/conclusion; commit statuses have state.
|
|
236
|
+
const result = c.conclusion || (c.status && c.status !== 'COMPLETED' ? 'PENDING' : null) || c.state || 'PENDING';
|
|
237
|
+
if (FAILED.has(result)) failed.push(c.name || c.context || 'check');
|
|
238
|
+
else if (!PASSED.has(result)) running += 1;
|
|
239
|
+
}
|
|
240
|
+
const state = !list.length ? 'none' : failed.length ? 'failed' : running ? 'running' : 'passed';
|
|
241
|
+
return { state, failed: failed.slice(0, 10), running, total: list.length };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Where a pull request stands on its way in: merged, closed, draft, fix, behind, ready or waiting. */
|
|
245
|
+
export function landState(pr) {
|
|
246
|
+
if (!pr?.state) return null;
|
|
247
|
+
if (pr.state === 'MERGED') return 'merged';
|
|
248
|
+
if (pr.state === 'CLOSED') return 'closed';
|
|
249
|
+
if (pr.isDraft) return 'draft';
|
|
250
|
+
const checks = pr.checks?.state;
|
|
251
|
+
if (checks === 'failed' || pr.review === 'CHANGES_REQUESTED' || pr.mergeable === 'CONFLICTING' || pr.mergeState === 'DIRTY') return 'fix';
|
|
252
|
+
if (pr.mergeState === 'BEHIND') return 'behind';
|
|
253
|
+
if (checks === 'running') return 'waiting';
|
|
254
|
+
if (pr.mergeState === 'CLEAN' || pr.mergeState === 'HAS_HOOKS' || pr.mergeState === 'UNSTABLE') return 'ready';
|
|
255
|
+
if (pr.review === 'APPROVED' && pr.mergeable === 'MERGEABLE') return 'ready';
|
|
256
|
+
return 'waiting';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Keep what we use from gh's PR JSON, small enough to store per agent. */
|
|
260
|
+
export function compactPr(p) {
|
|
261
|
+
if (!p) return p;
|
|
262
|
+
const { statusCheckRollup, reviewDecision, mergeStateStatus, headRefOid, mergeable, createdAt, mergedAt, ...rest } = p;
|
|
263
|
+
const out = { ...rest };
|
|
264
|
+
if (createdAt) out.openedAt = createdAt;
|
|
265
|
+
if (mergedAt) out.mergedAt = mergedAt;
|
|
266
|
+
if (statusCheckRollup !== undefined) out.checks = checkSummary(statusCheckRollup);
|
|
267
|
+
if (reviewDecision !== undefined) out.review = reviewDecision || null;
|
|
268
|
+
if (mergeable !== undefined) out.mergeable = mergeable || null;
|
|
269
|
+
if (mergeStateStatus !== undefined) out.mergeState = mergeStateStatus || null;
|
|
270
|
+
if (headRefOid) out.head = headRefOid;
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
221
274
|
/** PRs whose head is `branch`, via the gh CLI. Returns [] when gh is missing or unauthenticated. */
|
|
222
275
|
export async function prsForBranch(cwd, branch) {
|
|
223
276
|
if (process.env.AGENTMGR_NO_GH) return [];
|
|
224
277
|
if (!branch || branch === 'HEAD' || ['main', 'master', 'develop', 'trunk'].includes(branch)) return [];
|
|
225
278
|
try {
|
|
226
279
|
const out = await run(
|
|
227
|
-
|
|
228
|
-
['pr', 'list', '--head', branch, '--state', 'all', '--limit', '20', '--json',
|
|
280
|
+
ghBin(),
|
|
281
|
+
['pr', 'list', '--head', branch, '--state', 'all', '--limit', '20', '--json', PR_FIELDS],
|
|
229
282
|
{ cwd, timeout: 30_000 },
|
|
230
283
|
);
|
|
231
|
-
return JSON.parse(out || '[]').map((p) => ({ ...prFromUrl(p.url, 'branch'), ...p }));
|
|
284
|
+
return JSON.parse(out || '[]').map((p) => ({ ...prFromUrl(p.url, 'branch'), ...compactPr(p) }));
|
|
232
285
|
} catch {
|
|
233
286
|
return [];
|
|
234
287
|
}
|
|
@@ -238,8 +291,8 @@ export async function prsForBranch(cwd, branch) {
|
|
|
238
291
|
export async function prDetails(url) {
|
|
239
292
|
if (process.env.AGENTMGR_NO_GH) return null;
|
|
240
293
|
try {
|
|
241
|
-
const out = await run(
|
|
242
|
-
return JSON.parse(out);
|
|
294
|
+
const out = await run(ghBin(), ['pr', 'view', url, '--json', PR_FIELDS], { timeout: 30_000 });
|
|
295
|
+
return compactPr(JSON.parse(out));
|
|
243
296
|
} catch {
|
|
244
297
|
return null;
|
|
245
298
|
}
|