ai-agentmanager 1.14.0 → 1.16.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 +14 -0
- package/README.md +23 -0
- package/package.json +1 -1
- package/public/app.js +196 -33
- package/public/morph.js +89 -0
- package/public/review.js +14 -2
- package/public/scorecard.js +30 -4
- package/public/style.css +13 -0
- package/src/autopilot.js +184 -0
- package/src/backlog.js +21 -8
- package/src/cli.js +35 -2
- package/src/manager.js +34 -9
- package/src/repeats.js +155 -0
- package/src/review.js +14 -2
- package/src/scorecard.js +23 -0
- package/src/server.js +42 -6
package/src/repeats.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Requests you keep making: the asks you type (new agents and backlog items, not ones a playbook,
|
|
2
|
+
// trigger or the assistant started) are remembered, the last 500 in ~/.agentmanager/asks.json. When
|
|
3
|
+
// 3 or more read alike (the same words once numbers, quoted names, URLs, issue keys and filler are
|
|
4
|
+
// set aside), the inbox offers to save them as a playbook: the words they share become the request,
|
|
5
|
+
// the part that changes a {{what}} placeholder, and the projects they ran in its projects.
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { paths } from './config.js';
|
|
11
|
+
|
|
12
|
+
export const SUGGEST_AFTER = 3;
|
|
13
|
+
const KEEP = 500;
|
|
14
|
+
const ALIKE = 0.5; // share of distinct words two asks must have in common
|
|
15
|
+
const STOP = new Set('a an and are as at be by do does for from get have in into is it its make me my of on or our please should so that the their them then this to up use we with you your'.split(' '));
|
|
16
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
17
|
+
|
|
18
|
+
/** The words that say what an ask is about, without the parts that change from one to the next. */
|
|
19
|
+
export function words(text) {
|
|
20
|
+
const w = String(text ?? '')
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
23
|
+
.replace(/(^|\s)["'`“‘][^"'`”’\n]{1,80}["'`”’](?=[\s.,;:!?)]|$)/g, ' ')
|
|
24
|
+
.replace(/\b[a-z][a-z0-9]+-\d+\b/g, ' ')
|
|
25
|
+
.replace(/#\d+|\b\d[\w.]*/g, ' ')
|
|
26
|
+
.split(/[^a-z0-9_]+/)
|
|
27
|
+
.filter((x) => x.length > 1 && !STOP.has(x));
|
|
28
|
+
return [...new Set(w)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function alike(a, b) {
|
|
32
|
+
if (!a.length || !b.length) return 0;
|
|
33
|
+
const sb = new Set(b);
|
|
34
|
+
const both = a.filter((x) => sb.has(x)).length;
|
|
35
|
+
return both / (a.length + b.length - both);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Groups of asks that read alike, newest first; each group's seed is its newest ask. */
|
|
39
|
+
export function groups(asks, { threshold = ALIKE } = {}) {
|
|
40
|
+
const out = [];
|
|
41
|
+
// Newest first; asks recorded together keep their order.
|
|
42
|
+
const order = asks.map((a, i) => [a, i]).sort(([x, i], [y, j]) => y.at.localeCompare(x.at) || j - i);
|
|
43
|
+
for (const [ask] of order) {
|
|
44
|
+
const w = words(ask.text);
|
|
45
|
+
if (w.length < 2) continue;
|
|
46
|
+
const g = out.find((x) => alike(x.words, w) >= threshold);
|
|
47
|
+
if (g) g.asks.push(ask);
|
|
48
|
+
else out.push({ words: w, asks: [ask] });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A playbook request from asks that read alike: the words they all start and end with, around a
|
|
55
|
+
* {{what}} for the part that differs ("Upgrade react to the latest" / "Upgrade vite to the latest" →
|
|
56
|
+
* "Upgrade {{what}} to the latest"). When they share too little, the newest ask as it was written.
|
|
57
|
+
*/
|
|
58
|
+
export function template(texts) {
|
|
59
|
+
const split = texts.map((t) => String(t).trim().split(/\s+/));
|
|
60
|
+
if (texts.every((t) => t.trim() === texts[0].trim())) return texts[0].trim();
|
|
61
|
+
const shortest = Math.min(...split.map((s) => s.length));
|
|
62
|
+
const same = (get) => split.every((s) => get(s) === get(split[0]));
|
|
63
|
+
let pre = 0;
|
|
64
|
+
while (pre < shortest && same((s) => s[pre].toLowerCase())) pre++;
|
|
65
|
+
let suf = 0;
|
|
66
|
+
while (suf < shortest - pre && same((s) => s[s.length - 1 - suf].toLowerCase())) suf++;
|
|
67
|
+
if (pre + suf < Math.max(2, Math.ceil(shortest / 2))) return texts[0].trim();
|
|
68
|
+
const head = split[0].slice(0, pre).join(' ');
|
|
69
|
+
const tail = split[0].slice(split[0].length - suf).join(' ');
|
|
70
|
+
return [head, '{{what}}', tail].filter(Boolean).join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A short name for the playbook, from its request. */
|
|
74
|
+
export function nameFor(intent) {
|
|
75
|
+
const plain = intent.replace(/\{\{\w+\}\}/g, ' ').replace(/\s+/g, ' ').trim();
|
|
76
|
+
const w = plain.split(' ').slice(0, 6);
|
|
77
|
+
while (w.length > 1 && STOP.has(w[w.length - 1].toLowerCase())) w.pop();
|
|
78
|
+
const name = w.join(' ');
|
|
79
|
+
return (name.charAt(0).toUpperCase() + name.slice(1)).replace(/[.,:;]+$/, '').slice(0, 60) || 'Repeated request';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class Repeats extends EventEmitter {
|
|
83
|
+
constructor({ file = path.join(paths.home, 'asks.json'), suggestAfter = SUGGEST_AFTER, enabled = true, playbooks = () => [], projects = () => ({}) } = {}) {
|
|
84
|
+
super();
|
|
85
|
+
Object.assign(this, { file, suggestAfter, enabled, playbooks, projects });
|
|
86
|
+
try {
|
|
87
|
+
this.data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
88
|
+
} catch {
|
|
89
|
+
this.data = {};
|
|
90
|
+
}
|
|
91
|
+
this.data.asks ??= [];
|
|
92
|
+
this.data.dismissed ??= [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
save() {
|
|
96
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
97
|
+
fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
|
|
98
|
+
this.changed();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
changed() {
|
|
102
|
+
this.emit('change', this.view());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Asks the person typed: [{ text, projectKey }]. */
|
|
106
|
+
record(asks) {
|
|
107
|
+
const at = new Date().toISOString();
|
|
108
|
+
const list = asks.map((a) => ({ text: String(a.text ?? '').trim().slice(0, 2000), projectKey: a.projectKey ?? null, at })).filter((a) => a.text);
|
|
109
|
+
if (!list.length) return;
|
|
110
|
+
this.data.asks = [...this.data.asks, ...list].slice(-KEEP);
|
|
111
|
+
this.save();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Requests asked for at least suggestAfter times, not already a playbook and not dismissed. */
|
|
115
|
+
suggestions() {
|
|
116
|
+
if (!this.enabled) return [];
|
|
117
|
+
const books = this.playbooks().map((p) => words(p.intent));
|
|
118
|
+
const projects = this.projects();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const g of groups(this.data.asks)) {
|
|
121
|
+
if (g.asks.length < this.suggestAfter) continue;
|
|
122
|
+
if (books.some((b) => alike(b, g.words) >= ALIKE)) continue;
|
|
123
|
+
if (this.data.dismissed.some((d) => alike(d, g.words) >= ALIKE)) continue;
|
|
124
|
+
const intent = template(g.asks.map((a) => a.text));
|
|
125
|
+
const names = [...new Set(g.asks.map((a) => projects[a.projectKey]).filter(Boolean))];
|
|
126
|
+
out.push({
|
|
127
|
+
key: crypto.createHash('sha1').update([...g.words].sort().join(' ')).digest('hex').slice(0, 12),
|
|
128
|
+
name: nameFor(intent),
|
|
129
|
+
intent,
|
|
130
|
+
projects: names,
|
|
131
|
+
count: g.asks.length,
|
|
132
|
+
examples: [...new Set(g.asks.map((a) => a.text))].slice(0, 3),
|
|
133
|
+
lastAt: g.asks[0].at,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** "Not now": stop offering this one (asks like it keep counting, but it isn't offered again). */
|
|
140
|
+
dismiss(key) {
|
|
141
|
+
const g = groups(this.data.asks).find((x) => crypto.createHash('sha1').update([...x.words].sort().join(' ')).digest('hex').slice(0, 12) === key);
|
|
142
|
+
if (!g) throw err(404, 'No such suggestion');
|
|
143
|
+
this.data.dismissed = [...this.data.dismissed, g.words].slice(-100);
|
|
144
|
+
this.save();
|
|
145
|
+
return this.view();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
view() {
|
|
149
|
+
return { suggestions: this.suggestions(), suggestAfter: this.suggestAfter };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
inbox() {
|
|
153
|
+
return this.suggestions().map((s) => ({ ...s, kind: 'repeat', key: `repeat:${s.key}`, title: `Save "${s.name}" as a playbook?`, priority: 3 }));
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/review.js
CHANGED
|
@@ -9,6 +9,18 @@ import { ghBin } from './tracker.js';
|
|
|
9
9
|
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
10
10
|
const MAX_COMMENTS = 100;
|
|
11
11
|
|
|
12
|
+
/** A git command that writes the index, retried while another git process (the app's own status refresh) holds its lock. */
|
|
13
|
+
async function locked(args, opts, tries = 5) {
|
|
14
|
+
for (let i = 1; ; i++) {
|
|
15
|
+
try {
|
|
16
|
+
return await git.run('git', args, opts);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (i >= tries || !/index\.lock|another git process/i.test(e.message)) throw e;
|
|
19
|
+
await new Promise((r) => setTimeout(r, 300 * i));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
/** "origin/main @ 1a2b3c4d5" -> { ref: 'origin/main', sha: '1a2b3c4d5' } */
|
|
13
25
|
export function parseBaseRef(baseRef) {
|
|
14
26
|
const m = /^(.*) @ ([0-9a-f]{4,40})$/.exec(baseRef ?? '');
|
|
@@ -190,8 +202,8 @@ export class Review {
|
|
|
190
202
|
const opts = { cwd: agent.workdir, timeout: 60_000 };
|
|
191
203
|
const status = await git.run('git', ['status', '--porcelain'], opts);
|
|
192
204
|
if (!status.trim()) throw err(409, 'Nothing to commit');
|
|
193
|
-
await
|
|
194
|
-
await
|
|
205
|
+
await locked(['add', '-A'], opts);
|
|
206
|
+
await locked(['commit', '-m', message], opts);
|
|
195
207
|
const sha = await git.run('git', ['rev-parse', '--short', 'HEAD'], opts);
|
|
196
208
|
this.manager.plugins.emit('agent.commit', { agent: this.manager.auditView(agent), message, sha });
|
|
197
209
|
this.manager.refresh(agent.id).catch(() => {});
|
package/src/scorecard.js
CHANGED
|
@@ -156,6 +156,29 @@ export function combine(reply, f, hunks) {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
export const RECOMMENDATION = { ready: 'Ready to approve', look: 'Look at these first', not_ready: 'Not ready' };
|
|
159
|
+
const IMPROVE_BELOW = 80; // scores under this are named in the Improve message
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Improve: the agent's own scorecard sent back to it before a person reviews the work. The scores
|
|
163
|
+
* to raise (lowest first, with why), the acceptance criteria not met or not verified, and where a
|
|
164
|
+
* reviewer would look.
|
|
165
|
+
*/
|
|
166
|
+
export function improveMessage(sc) {
|
|
167
|
+
const weak = DIMENSIONS.filter(([k]) => sc.scores[k] && sc.scores[k].score < IMPROVE_BELOW)
|
|
168
|
+
.sort(([a], [b]) => sc.scores[a].score - sc.scores[b].score)
|
|
169
|
+
.map(([k, label]) => `- ${label} ${sc.scores[k].score}/100: ${[sc.scores[k].why, ...sc.scores[k].notes].filter(Boolean).join('; ')}`);
|
|
170
|
+
const criteria = sc.criteria.filter((c) => c.status !== 'met').map((c) => `- ${c.status === 'not_met' ? 'Not met' : 'Not verified'}: ${c.criterion}${c.evidence ? ` (${c.evidence})` : ''}`);
|
|
171
|
+
const look = sc.look.map((l) => `- ${l.file}: ${l.reason}`);
|
|
172
|
+
return [
|
|
173
|
+
`Before this goes to review: the review scorecard rates your changes ${sc.overall ?? '?'}/100 (${RECOMMENDATION[sc.recommendation]}${sc.reasons.length ? `: ${sc.reasons.join('; ')}` : ''}). Improve them where they fall short.`,
|
|
174
|
+
weak.length ? `Scores to raise:\n${weak.join('\n')}` : '',
|
|
175
|
+
criteria.length ? `Acceptance criteria:\n${criteria.join('\n')}` : '',
|
|
176
|
+
look.length ? `Where a reviewer would look first:\n${look.join('\n')}` : '',
|
|
177
|
+
'Fix what these point at: add or fix tests and run them, meet the criteria, handle the risky spots. Stay within the task, and say in the brief what you left as it is and why. Update the brief when you are done; the changes are scored again then.',
|
|
178
|
+
]
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.join('\n\n');
|
|
181
|
+
}
|
|
159
182
|
|
|
160
183
|
/** Markdown: for Copy / Download, the pull request's description and a PR comment. */
|
|
161
184
|
export function scorecardMarkdown(sc, title = '') {
|
package/src/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import express from 'express';
|
|
10
10
|
import { WebSocketServer } from 'ws';
|
|
11
11
|
import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.js';
|
|
12
|
-
import { AgentManager, MACHINE, shellNumber } from './manager.js';
|
|
12
|
+
import { AgentManager, MACHINE, projectOf, shellNumber } from './manager.js';
|
|
13
13
|
import { Backlog } from './backlog.js';
|
|
14
14
|
import { Races } from './races.js';
|
|
15
15
|
import { Review } from './review.js';
|
|
@@ -17,9 +17,11 @@ import { Teams } from './teams.js';
|
|
|
17
17
|
import { Assistant } from './assistant.js';
|
|
18
18
|
import { Cleanup } from './cleanup.js';
|
|
19
19
|
import { Landing } from './landing.js';
|
|
20
|
+
import { Autopilot } from './autopilot.js';
|
|
20
21
|
import { Budgets } from './budgets.js';
|
|
21
22
|
import { stats } from './stats.js';
|
|
22
23
|
import { Playbooks } from './playbooks.js';
|
|
24
|
+
import { Repeats } from './repeats.js';
|
|
23
25
|
import { Updates } from './update.js';
|
|
24
26
|
import { identity } from './identity.js';
|
|
25
27
|
import { Studio } from './studio.js';
|
|
@@ -90,6 +92,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
90
92
|
const teams = new Teams({ manager, store, config });
|
|
91
93
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
92
94
|
const landing = new Landing({ manager, store, config });
|
|
95
|
+
// Backlog work the scorecard calls ready goes on to a pull request (and merged) by itself.
|
|
96
|
+
const autopilot = new Autopilot({ manager, store, backlog, review, landing, config });
|
|
97
|
+
manager.inboxSources.push(() => autopilot.inbox());
|
|
93
98
|
const assistant = new Assistant({ manager, store, config });
|
|
94
99
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
95
100
|
const notifier = new Notifier({
|
|
@@ -104,6 +109,14 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
104
109
|
const triggers = new Triggers({ manager, store, config, notifier });
|
|
105
110
|
const budgets = new Budgets({ manager, store, config, notifier });
|
|
106
111
|
const playbooks = new Playbooks({ manager, store });
|
|
112
|
+
// Requests you keep typing, offered as a playbook.
|
|
113
|
+
const repeats = new Repeats({
|
|
114
|
+
suggestAfter: config.repeats?.suggestAfter,
|
|
115
|
+
enabled: config.repeats?.enabled !== false,
|
|
116
|
+
playbooks: () => playbooks.list(),
|
|
117
|
+
projects: () => Object.fromEntries(Object.values(store.data.projects ?? {}).map((p) => [p.key, p.shortcut || p.name])),
|
|
118
|
+
});
|
|
119
|
+
manager.inboxSources.push(() => repeats.inbox());
|
|
107
120
|
const memory = new Memory({ manager, config });
|
|
108
121
|
const updates = new Updates({ config });
|
|
109
122
|
let remoteServer = null;
|
|
@@ -230,6 +243,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
230
243
|
// Agents the assistant's own session starts: its limits apply, and they are marked as its.
|
|
231
244
|
const extra = assistant.admit(req.headers['x-agentmgr-agent'], list, opts);
|
|
232
245
|
const created = manager.createMany(list, { ...opts, ...extra });
|
|
246
|
+
if (!extra.startedBy) repeats.record(created.map((a) => ({ text: a.intent, projectKey: projectOf(store.get(a.id) ?? a).key })));
|
|
233
247
|
if (extra.startedBy) {
|
|
234
248
|
for (const a of created) notifier.send('assistant', a, `was started by the assistant: ${a.intent.replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
235
249
|
}
|
|
@@ -254,6 +268,15 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
254
268
|
);
|
|
255
269
|
api.get('/agents/:id/specs/:name', wrap((req) => manager.specDoc(req.params.id, req.params.name)));
|
|
256
270
|
api.post('/agents/:id/scorecard/post', wrap((req) => review.postScorecard(req.params.id)));
|
|
271
|
+
// Improve: its scorecard goes back to the agent; a backlog item in review is reopened with it.
|
|
272
|
+
api.post(
|
|
273
|
+
'/agents/:id/scorecard/improve',
|
|
274
|
+
wrap((req) => {
|
|
275
|
+
const id = manager.mustGet(req.params.id).id;
|
|
276
|
+
const it = backlog.items().find((i) => i.agentId === id && i.status === 'review');
|
|
277
|
+
return manager.improve(id, it ? { send: (note) => backlog.reopen(it.id, { note }) } : {});
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
257
280
|
api.post('/agents/:id/plan', wrap((req) => manager.startPlanning(req.params.id)));
|
|
258
281
|
api.post('/agents/:id/plan/approve', wrap((req) => manager.approvePlan(req.params.id, req.body?.note)));
|
|
259
282
|
api.post('/agents/:id/plan/changes', wrap((req) => manager.requestPlanChanges(req.params.id, req.body?.note)));
|
|
@@ -443,7 +466,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
443
466
|
'/backlog',
|
|
444
467
|
wrap((req) => {
|
|
445
468
|
const { items, item, ...opts } = req.body ?? {};
|
|
446
|
-
|
|
469
|
+
const added = backlog.add(Array.isArray(items) ? items : [item], opts);
|
|
470
|
+
repeats.record(added.map((it) => ({ text: it.title, projectKey: it.projectKey })));
|
|
471
|
+
return added;
|
|
447
472
|
}),
|
|
448
473
|
);
|
|
449
474
|
// Everything waiting on the user: live prompts, agents' questions, stuck agents, overlaps.
|
|
@@ -658,9 +683,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
658
683
|
};
|
|
659
684
|
}));
|
|
660
685
|
api.get('/playbooks', wrap(() => playbooks.list()));
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
686
|
+
// Saving a playbook can answer a repeated-request suggestion.
|
|
687
|
+
const withRepeats = (fn) => (req) => {
|
|
688
|
+
const out = fn(req);
|
|
689
|
+
repeats.changed();
|
|
690
|
+
return out;
|
|
691
|
+
};
|
|
692
|
+
api.post('/playbooks', wrap(withRepeats((req) => playbooks.create(req.body ?? {}))));
|
|
693
|
+
api.put('/playbooks/:id', wrap(withRepeats((req) => playbooks.update(req.params.id, req.body ?? {}))));
|
|
694
|
+
api.delete('/playbooks/:id', wrap(withRepeats((req) => playbooks.remove(req.params.id))));
|
|
695
|
+
api.get('/repeats', wrap(() => repeats.view()));
|
|
696
|
+
api.post('/repeats/dismiss', wrap((req) => repeats.dismiss(String(req.body?.key ?? ''))));
|
|
664
697
|
api.post('/playbooks/:id/run', wrap((req) => playbooks.run(req.params.id, { vars: req.body?.vars ?? {} })));
|
|
665
698
|
api.get('/stats', wrap((req) => {
|
|
666
699
|
const days = req.query.days === undefined ? 30 : Math.max(0, Math.min(3650, Math.floor(Number(req.query.days)) || 0));
|
|
@@ -713,6 +746,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
713
746
|
api.post('/backlog/:id/move', wrap((req) => backlog.move(req.params.id, req.body?.where)));
|
|
714
747
|
api.post('/backlog/:id/split', wrap((req) => backlog.split(req.params.id, req.body?.parts)));
|
|
715
748
|
api.post('/backlog/:id/keep-whole', wrap((req) => backlog.keepWhole(req.params.id)));
|
|
749
|
+
api.post('/backlog/:id/ship', wrap((req) => autopilot.shipNow(req.params.id)));
|
|
716
750
|
api.post(
|
|
717
751
|
'/shutdown',
|
|
718
752
|
wrap(() => {
|
|
@@ -923,6 +957,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
923
957
|
manager.on('change', (agent) => broadcast({ t: 'agent', agent }));
|
|
924
958
|
reports.on('change', () => broadcast({ t: 'built', reports: reports.list() }));
|
|
925
959
|
manager.rules.on('change', (rules) => broadcast({ t: 'rules', rules }));
|
|
960
|
+
repeats.on('change', (view) => broadcast({ t: 'repeats', repeats: view }));
|
|
926
961
|
manager.on('removed', (id) => {
|
|
927
962
|
broadcast({ t: 'removed', id });
|
|
928
963
|
live.closeAgent(id).catch(() => {});
|
|
@@ -959,7 +994,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
959
994
|
const url = new URL(req.url, 'http://x');
|
|
960
995
|
if (url.pathname === '/ws/events') {
|
|
961
996
|
eventClients.add(ws);
|
|
962
|
-
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view() }));
|
|
997
|
+
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view(), repeats: repeats.view() }));
|
|
963
998
|
ws.on('close', () => eventClients.delete(ws));
|
|
964
999
|
return;
|
|
965
1000
|
}
|
|
@@ -1040,6 +1075,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
1040
1075
|
closing = true;
|
|
1041
1076
|
cleanup.close();
|
|
1042
1077
|
landing.close();
|
|
1078
|
+
autopilot.close();
|
|
1043
1079
|
triggers.close();
|
|
1044
1080
|
budgets.close();
|
|
1045
1081
|
playbooks.close();
|