@framers/agentos-ext-meeting-browser 0.0.0-stage → 0.1.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/LICENSE +98 -0
- package/README.md +171 -2
- package/dist/BrowserMeetingProvider.d.ts +74 -0
- package/dist/BrowserMeetingProvider.d.ts.map +1 -0
- package/dist/BrowserMeetingProvider.js +136 -0
- package/dist/BrowserMeetingProvider.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +294 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +81 -0
- package/dist/protocol.js.map +1 -0
- package/dist/scheduler/channel.d.ts +45 -0
- package/dist/scheduler/channel.d.ts.map +1 -0
- package/dist/scheduler/channel.js +122 -0
- package/dist/scheduler/channel.js.map +1 -0
- package/dist/scheduler/docker.d.ts +86 -0
- package/dist/scheduler/docker.d.ts.map +1 -0
- package/dist/scheduler/docker.js +35 -0
- package/dist/scheduler/docker.js.map +1 -0
- package/dist/scheduler/main.d.ts +3 -0
- package/dist/scheduler/main.d.ts.map +1 -0
- package/dist/scheduler/main.js +79 -0
- package/dist/scheduler/main.js.map +1 -0
- package/dist/scheduler/runs.d.ts +75 -0
- package/dist/scheduler/runs.d.ts.map +1 -0
- package/dist/scheduler/runs.js +123 -0
- package/dist/scheduler/runs.js.map +1 -0
- package/dist/scheduler/server.d.ts +177 -0
- package/dist/scheduler/server.d.ts.map +1 -0
- package/dist/scheduler/server.js +653 -0
- package/dist/scheduler/server.js.map +1 -0
- package/dist/scheduler/urls.d.ts +28 -0
- package/dist/scheduler/urls.d.ts.map +1 -0
- package/dist/scheduler/urls.js +49 -0
- package/dist/scheduler/urls.js.map +1 -0
- package/manifest.json +25 -0
- package/package.json +73 -3
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { WebSocketServer } from 'ws';
|
|
6
|
+
import { CommandMessage, StartRunRequest } from '../protocol.js';
|
|
7
|
+
import { ChannelPeer } from './channel.js';
|
|
8
|
+
import { containerOptions, LABEL_RUN, LABEL_WORKER, PROFILE_MOUNT } from './docker.js';
|
|
9
|
+
import { RunTable, sameToken } from './runs.js';
|
|
10
|
+
import { checkJoinUrl, defaultResolver } from './urls.js';
|
|
11
|
+
/** The file an identity's provisioning writes in its folder once its profile is signed in; a folder without it is not in the pool. */
|
|
12
|
+
export const PROVISIONED_MARKER = 'provisioned';
|
|
13
|
+
/** The file that takes an identity out of the pool until it is signed in again; the bot's `needs_reauth` error report writes it. */
|
|
14
|
+
export const NEEDS_REAUTH_MARKER = 'needs_reauth';
|
|
15
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
16
|
+
/** A run id names a state file and a container, so it is a plain name: letters, digits, `.`, `_` and `-`. */
|
|
17
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
18
|
+
/** How long an exited run stays listed by `GET /runs` (with its state file) when the product does not delete it. */
|
|
19
|
+
const EXITED_KEPT_MS = 3_600_000;
|
|
20
|
+
const REPORT_ATTEMPTS = 3;
|
|
21
|
+
const REPORT_TIMEOUT_MS = 10_000;
|
|
22
|
+
/**
|
|
23
|
+
* Reads a list of listen addresses: `host:port` entries separated by commas (`127.0.0.1:8788,10.90.0.1:8788`), an IPv6
|
|
24
|
+
* host in brackets (`[::1]:8788`). Throws on an entry that is not one, and on an empty list.
|
|
25
|
+
*/
|
|
26
|
+
export function parseListenAddresses(text) {
|
|
27
|
+
const addresses = [];
|
|
28
|
+
for (const entry of text.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
29
|
+
const m = /^(?:\[([0-9A-Fa-f:.]+)\]|([^\s:[\]]+)):(\d{1,5})$/.exec(entry);
|
|
30
|
+
const port = m ? Number(m[3]) : Number.NaN;
|
|
31
|
+
if (!m || port > 65_535)
|
|
32
|
+
throw new Error(`not a listen address (host:port): ${entry}`);
|
|
33
|
+
addresses.push({ host: m[1] ?? m[2], port });
|
|
34
|
+
}
|
|
35
|
+
if (!addresses.length)
|
|
36
|
+
throw new Error('no listen address (host:port)');
|
|
37
|
+
return addresses;
|
|
38
|
+
}
|
|
39
|
+
const SILENT = { info: () => undefined, warn: () => undefined, error: () => undefined };
|
|
40
|
+
/** An error as a log field: its name and message, which for Docker, file and network errors carry no meeting content. */
|
|
41
|
+
function describe(err) {
|
|
42
|
+
return err instanceof Error ? `${err.name}: ${err.message}` : 'unknown error';
|
|
43
|
+
}
|
|
44
|
+
function urlHost(host) {
|
|
45
|
+
return host.includes(':') ? `[${host}]` : host;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The runner on a worker machine. It is not the source of truth (the meeting manager's job store is): it leases a slot
|
|
49
|
+
* per run, starts one container per run under a fixed policy, holds the container's command channel, relays the
|
|
50
|
+
* product's commands down it, forwards the container's reports to the product under the run's token, and answers
|
|
51
|
+
* `GET /runs` from the runs it holds, which after a restart are the ones its state files and Docker agree on.
|
|
52
|
+
*
|
|
53
|
+
* Its boundary: the product's requests carry the client credential and name a run this scheduler leased; nothing
|
|
54
|
+
* customer-supplied reaches Docker but the job, which travels over the channel, and the run's token, which travels on the
|
|
55
|
+
* container's stdin; the image and the limits are this configuration's; a join link is checked before a container
|
|
56
|
+
* starts (the platform's host, no address in a private range).
|
|
57
|
+
*/
|
|
58
|
+
export class Scheduler {
|
|
59
|
+
options;
|
|
60
|
+
servers;
|
|
61
|
+
wss;
|
|
62
|
+
runs;
|
|
63
|
+
peers = new Map();
|
|
64
|
+
forwarding = new Map();
|
|
65
|
+
policy;
|
|
66
|
+
testHosts;
|
|
67
|
+
fetch;
|
|
68
|
+
now;
|
|
69
|
+
log;
|
|
70
|
+
resolver;
|
|
71
|
+
paused = false;
|
|
72
|
+
stopped = false;
|
|
73
|
+
sweeping = false;
|
|
74
|
+
port = 0;
|
|
75
|
+
sweeper;
|
|
76
|
+
/** @param options - The worker's id, its slots, the client credential, the image, the Docker client and where it listens. */
|
|
77
|
+
constructor(options) {
|
|
78
|
+
this.options = options;
|
|
79
|
+
if (!Number.isInteger(options.slots) || options.slots < 1)
|
|
80
|
+
throw new Error('the scheduler needs a whole number of slots, one or more');
|
|
81
|
+
if (options.clientToken.length < 16)
|
|
82
|
+
throw new Error('the client credential must be at least 16 characters');
|
|
83
|
+
this.now = options.now ?? Date.now;
|
|
84
|
+
this.log = options.logger ?? SILENT;
|
|
85
|
+
this.runs = new RunTable(options.slots, options.stateRoot, this.now);
|
|
86
|
+
this.resolver = options.resolve ?? defaultResolver;
|
|
87
|
+
this.testHosts = (options.testHosts ?? []).map((host) => host.toLowerCase());
|
|
88
|
+
this.fetch = options.fetchImpl ?? globalThis.fetch;
|
|
89
|
+
this.policy = { image: options.image, network: 'meeting-bots', memoryBytes: 3 * 1024 ** 3, nanoCpus: 2e9, pidsLimit: 512, shmBytes: 1024 ** 3, channelUrl: options.channelUrl ?? '', ...options.policy };
|
|
90
|
+
this.wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
|
91
|
+
this.servers = this.addresses().map(() => {
|
|
92
|
+
const server = createServer((req, res) => void this.route(req, res).catch((err) => {
|
|
93
|
+
this.log.error('a request failed', { method: req.method, error: describe(err) });
|
|
94
|
+
if (!res.headersSent)
|
|
95
|
+
res.writeHead(500);
|
|
96
|
+
res.end();
|
|
97
|
+
}));
|
|
98
|
+
server.on('upgrade', (req, socket, head) => this.upgrade(req, socket, head));
|
|
99
|
+
return server;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** The HTTP base url on the first listen address, with the port it was given or the one it was assigned. */
|
|
103
|
+
get url() {
|
|
104
|
+
return `http://${urlHost(this.options.listen.host)}:${this.port}`;
|
|
105
|
+
}
|
|
106
|
+
/** The same address for WebSockets: the command channel is `<wsUrl>/channel`. */
|
|
107
|
+
get wsUrl() {
|
|
108
|
+
return `ws://${urlHost(this.options.listen.host)}:${this.port}`;
|
|
109
|
+
}
|
|
110
|
+
/** Replaces the resolver the join-link check uses. */
|
|
111
|
+
setResolver(r) {
|
|
112
|
+
this.resolver = r;
|
|
113
|
+
}
|
|
114
|
+
/** Listens on every address, takes back the runs a previous scheduler left, and starts the sweeper. */
|
|
115
|
+
async start() {
|
|
116
|
+
const addresses = this.addresses();
|
|
117
|
+
for (const [index, server] of this.servers.entries()) {
|
|
118
|
+
const { host, port } = addresses[index];
|
|
119
|
+
await new Promise((resolve, reject) => {
|
|
120
|
+
server.once('error', reject);
|
|
121
|
+
server.listen(port, host, () => {
|
|
122
|
+
server.off('error', reject);
|
|
123
|
+
resolve();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const address = this.servers[0].address();
|
|
128
|
+
this.port = typeof address === 'object' && address ? address.port : this.options.listen.port;
|
|
129
|
+
if (!this.policy.channelUrl)
|
|
130
|
+
this.policy.channelUrl = `${this.wsUrl}/channel`;
|
|
131
|
+
await this.adopt();
|
|
132
|
+
this.sweeper = setInterval(() => void this.sweep(), this.options.sweepEveryMs ?? 5_000);
|
|
133
|
+
this.sweeper.unref?.();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Stops listening and closes every channel (`1001`). The containers keep running, and the next scheduler on the same
|
|
137
|
+
* state folder takes them back. A stopped scheduler acts on nothing more: no report, no state write, no kill.
|
|
138
|
+
*/
|
|
139
|
+
async stop() {
|
|
140
|
+
this.stopped = true;
|
|
141
|
+
if (this.sweeper)
|
|
142
|
+
clearInterval(this.sweeper);
|
|
143
|
+
for (const peer of this.peers.values())
|
|
144
|
+
peer.close(1001, 'scheduler stopping');
|
|
145
|
+
this.peers.clear();
|
|
146
|
+
this.wss.close();
|
|
147
|
+
await Promise.all(this.servers.map((server) => new Promise((resolve) => server.close(() => resolve()))));
|
|
148
|
+
}
|
|
149
|
+
/** Takes no new run; `GET /capacity` says so. The runs it holds go on. */
|
|
150
|
+
pause() {
|
|
151
|
+
this.paused = true;
|
|
152
|
+
}
|
|
153
|
+
/** Takes new runs again. */
|
|
154
|
+
resume() {
|
|
155
|
+
this.paused = false;
|
|
156
|
+
}
|
|
157
|
+
addresses() {
|
|
158
|
+
return [this.options.listen, ...(this.options.alsoListen ?? [])];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* After a restart: every run with a state file is taken back whole (its token, its destination, its limits, its start
|
|
162
|
+
* time, its report sequence), its container's exit is watched again, and its bot's channel reconnects with the token it
|
|
163
|
+
* holds; a container of ours with no state file is stopped and removed (nothing can command or report it); a state
|
|
164
|
+
* file whose container is gone is reported `crashed` to the product, unless it had already exited.
|
|
165
|
+
*/
|
|
166
|
+
async adopt() {
|
|
167
|
+
const found = await this.options.docker.listContainers({ all: true, filters: { label: [`${LABEL_WORKER}=${this.options.workerId}`] } });
|
|
168
|
+
const byRun = new Map(found.map((c) => [c.Labels[LABEL_RUN], c]));
|
|
169
|
+
const known = this.options.stateRoot ? RunTable.readStateFiles(this.options.stateRoot) : [];
|
|
170
|
+
for (const run of known) {
|
|
171
|
+
const runId = run.job.runId;
|
|
172
|
+
const c = byRun.get(runId);
|
|
173
|
+
byRun.delete(runId);
|
|
174
|
+
this.runs.adopt(run);
|
|
175
|
+
if (!c) {
|
|
176
|
+
if (run.state !== 'exited') {
|
|
177
|
+
run.state = 'exited';
|
|
178
|
+
run.exitedAt = this.now();
|
|
179
|
+
this.log.warn('a run lost its container across a restart', { runId });
|
|
180
|
+
void this.report(run, 'crashed', { seconds: this.seconds(run), message: 'container gone across a scheduler restart' });
|
|
181
|
+
}
|
|
182
|
+
this.runs.remove(runId);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
run.containerId = c.Id;
|
|
186
|
+
this.runs.persist(run);
|
|
187
|
+
if (c.State !== 'running') {
|
|
188
|
+
await this.onExit(run, 137); // it ended while no scheduler watched: its code is unknown, so it counts as crashed
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
this.watch(run, this.options.docker.getContainer(c.Id));
|
|
192
|
+
this.log.info('took a run back after a restart; waiting for its channel', { runId, state: run.state });
|
|
193
|
+
}
|
|
194
|
+
for (const [runId, c] of byRun) {
|
|
195
|
+
this.log.warn('a container of this worker has no state file; stopping it', { runId: runId ?? 'unknown' });
|
|
196
|
+
const container = this.options.docker.getContainer(c.Id);
|
|
197
|
+
await container.stop({ t: 5 }).catch(() => undefined);
|
|
198
|
+
await container.remove({ force: true }).catch(() => undefined);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
authorized(req) {
|
|
202
|
+
const header = req.headers.authorization;
|
|
203
|
+
return header?.startsWith('Bearer ') === true && sameToken(header.slice(7), this.options.clientToken);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The request's JSON body. One over the limit is read to its end and dropped, so the 413 reaches the client (leaving a
|
|
207
|
+
* stream's iterator early destroys the socket the answer would go out on).
|
|
208
|
+
*/
|
|
209
|
+
body(req) {
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
const chunks = [];
|
|
212
|
+
let size = 0;
|
|
213
|
+
req.on('data', (chunk) => {
|
|
214
|
+
size += chunk.length;
|
|
215
|
+
if (size <= MAX_BODY_BYTES)
|
|
216
|
+
chunks.push(chunk);
|
|
217
|
+
});
|
|
218
|
+
req.on('error', () => resolve({ ok: false, status: 400 }));
|
|
219
|
+
req.on('end', () => {
|
|
220
|
+
if (size > MAX_BODY_BYTES)
|
|
221
|
+
return resolve({ ok: false, status: 413 });
|
|
222
|
+
if (!chunks.length)
|
|
223
|
+
return resolve({ ok: true, value: undefined });
|
|
224
|
+
try {
|
|
225
|
+
resolve({ ok: true, value: JSON.parse(Buffer.concat(chunks).toString('utf8')) });
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
resolve({ ok: false, status: 400 }); // the parser's message quotes the body, which can hold a meeting link: never logged
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
json(res, status, body) {
|
|
234
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
235
|
+
res.end(JSON.stringify(body));
|
|
236
|
+
}
|
|
237
|
+
async route(req, res) {
|
|
238
|
+
const url = new URL(req.url ?? '/', 'http://scheduler');
|
|
239
|
+
if (url.pathname === '/health')
|
|
240
|
+
return this.json(res, 200, { ok: true, workerId: this.options.workerId });
|
|
241
|
+
if (!this.authorized(req))
|
|
242
|
+
return this.json(res, 401, { error: 'unauthorized' });
|
|
243
|
+
if (req.method === 'GET' && url.pathname === '/capacity')
|
|
244
|
+
return this.json(res, 200, { workerId: this.options.workerId, slots: this.runs.slots, busy: this.runs.busy, paused: this.paused });
|
|
245
|
+
if (req.method === 'GET' && url.pathname === '/runs')
|
|
246
|
+
return this.json(res, 200, this.runs.list());
|
|
247
|
+
if (req.method === 'POST' && url.pathname === '/runs')
|
|
248
|
+
return this.startRun(req, res);
|
|
249
|
+
const m = url.pathname.match(/^\/runs\/([^/]+)(?:\/(command))?$/);
|
|
250
|
+
if (m) {
|
|
251
|
+
let runId;
|
|
252
|
+
try {
|
|
253
|
+
runId = decodeURIComponent(m[1]);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return this.json(res, 404, { error: 'no such run' });
|
|
257
|
+
}
|
|
258
|
+
const run = this.runs.get(runId);
|
|
259
|
+
if (!run)
|
|
260
|
+
return this.json(res, 404, { error: 'no such run' });
|
|
261
|
+
if (req.method === 'POST' && m[2] === 'command')
|
|
262
|
+
return this.commandRun(run, req, res);
|
|
263
|
+
if (req.method === 'DELETE' && !m[2])
|
|
264
|
+
return this.deleteRun(run, res);
|
|
265
|
+
}
|
|
266
|
+
this.json(res, 404, { error: 'no such route' });
|
|
267
|
+
}
|
|
268
|
+
refuse(res, runId, message) {
|
|
269
|
+
this.log.info('refused a run', { runId, reason: message });
|
|
270
|
+
this.json(res, 400, { error: message });
|
|
271
|
+
}
|
|
272
|
+
async startRun(req, res) {
|
|
273
|
+
const body = await this.body(req);
|
|
274
|
+
if (!body.ok)
|
|
275
|
+
return this.json(res, body.status, { error: 'bad request' });
|
|
276
|
+
const parsed = StartRunRequest.safeParse(body.value);
|
|
277
|
+
if (!parsed.success || !RUN_ID.test(parsed.data.runId))
|
|
278
|
+
return this.json(res, 400, { error: 'bad request' });
|
|
279
|
+
const request = parsed.data;
|
|
280
|
+
const runId = request.runId;
|
|
281
|
+
const held = this.runs.get(runId);
|
|
282
|
+
// the same dispatch asked again, its first answer lost on the way back: the run it started, and no second container
|
|
283
|
+
if (held && sameToken(held.workerToken, request.workerToken))
|
|
284
|
+
return this.json(res, 201, this.runs.status(held));
|
|
285
|
+
if (this.paused)
|
|
286
|
+
return this.json(res, 409, { error: 'paused' });
|
|
287
|
+
const checked = await checkJoinUrl(request.meetingUrl, this.resolver, this.testHosts);
|
|
288
|
+
if (!checked.ok)
|
|
289
|
+
return this.refuse(res, runId, checked.message);
|
|
290
|
+
if (checked.platform !== request.platform)
|
|
291
|
+
return this.refuse(res, runId, 'the link is not the platform');
|
|
292
|
+
const earlier = this.runs.get(runId);
|
|
293
|
+
// the same dispatch asked again while this one checked its link: the other request took the run under this token
|
|
294
|
+
// after the check above; replacing that run would orphan the container it is starting
|
|
295
|
+
if (earlier && sameToken(earlier.workerToken, request.workerToken))
|
|
296
|
+
return this.json(res, 201, this.runs.status(earlier));
|
|
297
|
+
if (earlier)
|
|
298
|
+
await this.replace(earlier);
|
|
299
|
+
if (this.runs.get(runId))
|
|
300
|
+
return this.json(res, 503, { error: 'the run is being started' });
|
|
301
|
+
const profilesRoot = this.policy.profilesRoot;
|
|
302
|
+
let identity;
|
|
303
|
+
if (request.platform === 'google_meet' && !checked.testHost && profilesRoot) {
|
|
304
|
+
identity = this.pickIdentity(profilesRoot);
|
|
305
|
+
if (!identity) {
|
|
306
|
+
this.log.warn('a Meet run found no free identity', { runId });
|
|
307
|
+
return this.json(res, 409, { error: 'no identity' });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const profileDir = identity && profilesRoot ? join(profilesRoot, identity) : undefined;
|
|
311
|
+
const job = { type: 'job', runId, platform: request.platform, meetingUrl: request.meetingUrl, botName: request.botName, notice: request.notice, stopPhrase: request.stopPhrase, limits: request.limits, livekit: request.livekit, profileDir: profileDir ? PROFILE_MOUNT : undefined, adapter: checked.testHost ? 'fixture' : undefined };
|
|
312
|
+
const run = { job, workerToken: request.workerToken, reportUrl: request.reportUrl, state: 'starting', startedAt: this.now(), reportSeq: 0, identity };
|
|
313
|
+
if (!this.runs.lease(run))
|
|
314
|
+
return this.json(res, 409, { error: 'no slot' });
|
|
315
|
+
try {
|
|
316
|
+
const container = await this.options.docker.createContainer(containerOptions(this.policy, runId, this.options.workerId, profileDir));
|
|
317
|
+
run.containerId = container.id;
|
|
318
|
+
this.runs.persist(run);
|
|
319
|
+
await container.start();
|
|
320
|
+
this.watch(run, container);
|
|
321
|
+
await this.writeStdin(container.id, `${request.workerToken}\n`);
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
// a container left running without its token would wait on its stdin for ever: it goes with the run
|
|
325
|
+
if (this.runs.get(runId) === run)
|
|
326
|
+
this.runs.remove(runId);
|
|
327
|
+
await this.killContainer(run);
|
|
328
|
+
this.log.error('a bot container did not start', { runId, error: describe(err) });
|
|
329
|
+
return this.json(res, 500, { error: 'container start failed' });
|
|
330
|
+
}
|
|
331
|
+
this.log.info('started a bot container', { runId, platform: request.platform, identity, fixture: checked.testHost });
|
|
332
|
+
this.json(res, 201, this.runs.status(run));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* A later dispatch of a run this scheduler holds: the manager minted a new token for it, so the earlier container,
|
|
336
|
+
* whose token the product no longer accepts, is stopped, and the run starts again under the new token. Nothing is
|
|
337
|
+
* reported for the earlier container.
|
|
338
|
+
*/
|
|
339
|
+
async replace(earlier) {
|
|
340
|
+
const runId = earlier.job.runId;
|
|
341
|
+
this.runs.remove(runId);
|
|
342
|
+
const peer = this.peers.get(runId);
|
|
343
|
+
if (peer) {
|
|
344
|
+
this.peers.delete(runId);
|
|
345
|
+
peer.close(4410, 'the run was dispatched again');
|
|
346
|
+
}
|
|
347
|
+
if (earlier.state !== 'exited')
|
|
348
|
+
this.log.warn('a run dispatched again replaces its live container', { runId });
|
|
349
|
+
await this.killContainer(earlier);
|
|
350
|
+
}
|
|
351
|
+
async commandRun(run, req, res) {
|
|
352
|
+
const body = await this.body(req);
|
|
353
|
+
if (!body.ok)
|
|
354
|
+
return this.json(res, body.status, { error: 'bad command' });
|
|
355
|
+
const parsed = CommandMessage.omit({ type: true, id: true }).safeParse(body.value);
|
|
356
|
+
if (!parsed.success)
|
|
357
|
+
return this.json(res, 400, { error: 'bad command' });
|
|
358
|
+
const command = parsed.data;
|
|
359
|
+
const runId = run.job.runId;
|
|
360
|
+
if (command.name === 'kill') {
|
|
361
|
+
this.log.info('killing a bot container on command', { runId });
|
|
362
|
+
await this.killContainer(run);
|
|
363
|
+
return this.json(res, 200, { ok: true });
|
|
364
|
+
}
|
|
365
|
+
if (run.state === 'exited')
|
|
366
|
+
return this.json(res, 404, { error: 'the run has ended' });
|
|
367
|
+
const peer = this.peers.get(runId);
|
|
368
|
+
if (command.name === 'leave') {
|
|
369
|
+
if (!peer && run.state === 'starting') {
|
|
370
|
+
// its bot has not opened its channel, so it has no job: it is in no meeting, and stopping it says nothing to anyone
|
|
371
|
+
this.log.info('stopping a bot told to leave before its channel opened', { runId });
|
|
372
|
+
await this.killContainer(run);
|
|
373
|
+
return this.json(res, 200, { ok: true });
|
|
374
|
+
}
|
|
375
|
+
if (run.state !== 'leaving') {
|
|
376
|
+
run.state = 'leaving';
|
|
377
|
+
run.leaveCommandedAt = this.now();
|
|
378
|
+
run.leaveReason = command.reason;
|
|
379
|
+
this.runs.persist(run);
|
|
380
|
+
}
|
|
381
|
+
// a bot reconnecting after a restart: the leave goes down its next channel, and the kill timer runs meanwhile
|
|
382
|
+
if (!peer)
|
|
383
|
+
return this.json(res, 202, { ok: true });
|
|
384
|
+
}
|
|
385
|
+
if (!peer)
|
|
386
|
+
return this.json(res, 409, { error: 'the bot has not connected its channel' });
|
|
387
|
+
const ack = await peer.command({ type: 'command', id: randomUUID(), ...command });
|
|
388
|
+
if (!ack.ok)
|
|
389
|
+
this.log.warn('a bot did not acknowledge a command', { runId, command: command.name });
|
|
390
|
+
this.json(res, ack.ok ? 200 : 500, ack);
|
|
391
|
+
}
|
|
392
|
+
async deleteRun(run, res) {
|
|
393
|
+
const runId = run.job.runId;
|
|
394
|
+
this.runs.remove(runId); // first, so the exit that follows reports nothing for a run the product has deleted
|
|
395
|
+
const peer = this.peers.get(runId);
|
|
396
|
+
if (peer) {
|
|
397
|
+
this.peers.delete(runId);
|
|
398
|
+
peer.close(1000, 'the run was deleted');
|
|
399
|
+
}
|
|
400
|
+
await this.killContainer(run);
|
|
401
|
+
this.log.info('deleted a run and its container', { runId });
|
|
402
|
+
this.json(res, 200, { deleted: true });
|
|
403
|
+
}
|
|
404
|
+
async killContainer(run) {
|
|
405
|
+
if (!run.containerId)
|
|
406
|
+
return;
|
|
407
|
+
const c = this.options.docker.getContainer(run.containerId);
|
|
408
|
+
await c.stop({ t: 5 }).catch(() => undefined);
|
|
409
|
+
await c.remove({ force: true }).catch(() => undefined);
|
|
410
|
+
}
|
|
411
|
+
/** Watches a container's exit; a watch that fails (the Docker socket lost) stops the container, so no bot outlives its watch. */
|
|
412
|
+
watch(run, container) {
|
|
413
|
+
container
|
|
414
|
+
.wait()
|
|
415
|
+
.then((result) => this.onExit(run, result.StatusCode), async (err) => {
|
|
416
|
+
if (this.stopped)
|
|
417
|
+
return;
|
|
418
|
+
this.log.warn('lost the exit watch of a bot container; stopping it', { runId: run.job.runId, error: describe(err) });
|
|
419
|
+
await this.killContainer(run);
|
|
420
|
+
await this.onExit(run, 137);
|
|
421
|
+
})
|
|
422
|
+
.catch((err) => this.log.error('the exit of a bot container was not handled', { runId: run.job.runId, error: describe(err) }));
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* A container's exit: the run is marked exited, its channel closed, and `done` (exit 0) or `crashed` reported, unless
|
|
426
|
+
* the bot reported its own end first; the container is removed. The exit of a container whose run was deleted or
|
|
427
|
+
* dispatched again only removes it.
|
|
428
|
+
*/
|
|
429
|
+
async onExit(run, code) {
|
|
430
|
+
if (this.stopped)
|
|
431
|
+
return;
|
|
432
|
+
const runId = run.job.runId;
|
|
433
|
+
if (this.runs.get(runId) !== run) {
|
|
434
|
+
if (run.containerId)
|
|
435
|
+
await this.options.docker.getContainer(run.containerId).remove({ force: true }).catch(() => undefined);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const wasExited = run.state === 'exited';
|
|
439
|
+
run.state = 'exited';
|
|
440
|
+
run.exitedAt ??= this.now();
|
|
441
|
+
this.runs.persist(run);
|
|
442
|
+
const peer = this.peers.get(runId);
|
|
443
|
+
if (peer) {
|
|
444
|
+
this.peers.delete(runId);
|
|
445
|
+
peer.close(1000, 'container exited');
|
|
446
|
+
}
|
|
447
|
+
if (!wasExited) {
|
|
448
|
+
this.log.info('a bot container exited', { runId, code });
|
|
449
|
+
if (!run.finalReported)
|
|
450
|
+
void this.report(run, code === 0 ? 'done' : 'crashed', { seconds: this.seconds(run), message: code === 0 ? undefined : `exit ${code}` });
|
|
451
|
+
}
|
|
452
|
+
if (run.containerId)
|
|
453
|
+
await this.options.docker.getContainer(run.containerId).remove({ force: true }).catch(() => undefined);
|
|
454
|
+
}
|
|
455
|
+
seconds(run) {
|
|
456
|
+
return Math.round(((run.exitedAt ?? this.now()) - run.startedAt) / 1000);
|
|
457
|
+
}
|
|
458
|
+
/** A report the scheduler makes itself (an exit, a lost container): numbered after the bot's last, and queued behind it. */
|
|
459
|
+
report(run, event, data) {
|
|
460
|
+
const report = { type: 'report', seq: ++run.reportSeq, event, at: this.now(), data };
|
|
461
|
+
this.runs.persist(run);
|
|
462
|
+
return this.enqueue(run, report);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* A run told to leave that has not exited within `killAfterMs` is killed; a bot that has not opened its channel within
|
|
466
|
+
* `connectWithinMs` of its start is killed (it never had its job); a run alive a minute past its whole length is killed
|
|
467
|
+
* too (the limit plus sixty seconds, enforced here whatever the bot or the manager did); the exit path reports each as
|
|
468
|
+
* `crashed`. An exited run is forgotten an hour after its exit.
|
|
469
|
+
*/
|
|
470
|
+
async sweep() {
|
|
471
|
+
if (this.sweeping || this.stopped)
|
|
472
|
+
return;
|
|
473
|
+
this.sweeping = true;
|
|
474
|
+
try {
|
|
475
|
+
const now = this.now();
|
|
476
|
+
const killAfterMs = this.options.killAfterMs ?? 30_000;
|
|
477
|
+
const connectWithinMs = this.options.connectWithinMs ?? 120_000;
|
|
478
|
+
for (const run of this.runs.values()) {
|
|
479
|
+
const runId = run.job.runId;
|
|
480
|
+
if (run.state === 'exited') {
|
|
481
|
+
if (now - (run.exitedAt ?? run.startedAt) > EXITED_KEPT_MS)
|
|
482
|
+
this.runs.remove(runId);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (run.state === 'leaving' && run.leaveCommandedAt !== undefined && now - run.leaveCommandedAt > killAfterMs) {
|
|
486
|
+
run.leaveCommandedAt = undefined;
|
|
487
|
+
this.runs.persist(run);
|
|
488
|
+
this.log.warn('a bot told to leave has not exited; killing it', { runId });
|
|
489
|
+
await this.killContainer(run);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (run.state === 'starting' && now - run.startedAt > connectWithinMs) {
|
|
493
|
+
this.log.warn('a bot has not opened its channel; killing it', { runId });
|
|
494
|
+
await this.killContainer(run);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (now - run.startedAt > run.job.limits.totalMs + 60_000) {
|
|
498
|
+
this.log.warn('a run is a minute past its limit; killing it', { runId });
|
|
499
|
+
await this.killContainer(run);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
this.log.error('a sweep failed', { error: describe(err) });
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
this.sweeping = false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
upgrade(req, socket, head) {
|
|
511
|
+
const onSocketError = (err) => this.log.debug?.('a channel handshake failed', { error: describe(err) });
|
|
512
|
+
socket.on('error', onSocketError);
|
|
513
|
+
const path = (req.url ?? '/').split('?')[0];
|
|
514
|
+
const header = req.headers.authorization;
|
|
515
|
+
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
|
|
516
|
+
const run = !this.stopped && path === '/channel' && token ? this.runs.byToken(token) : undefined;
|
|
517
|
+
if (!run) {
|
|
518
|
+
this.log.info('refused a channel: no live run holds its token');
|
|
519
|
+
socket.once('finish', () => socket.destroy());
|
|
520
|
+
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
socket.off('error', onSocketError);
|
|
524
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => this.connect(run, ws));
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* A bot's channel, after its token named a live run: it is the run's one channel (a second is closed with `4409`), it
|
|
528
|
+
* gets the job as its first message, its reports are forwarded, and a leave taken while it was away goes down it next.
|
|
529
|
+
*/
|
|
530
|
+
connect(run, ws) {
|
|
531
|
+
const runId = run.job.runId;
|
|
532
|
+
ws.on('error', () => undefined); // a socket error is followed by its close
|
|
533
|
+
if (this.peers.has(runId)) {
|
|
534
|
+
ws.close(4409, 'a channel is already open for this run');
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (this.stopped || this.runs.get(runId) !== run || run.state === 'exited') {
|
|
538
|
+
ws.close(4410, 'the run has ended'); // deleted, dispatched again or exited during the handshake
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const peer = new ChannelPeer(ws);
|
|
542
|
+
this.peers.set(runId, peer);
|
|
543
|
+
if (run.state === 'starting') {
|
|
544
|
+
run.state = 'running';
|
|
545
|
+
this.runs.persist(run);
|
|
546
|
+
}
|
|
547
|
+
peer.onReport((report) => this.onReport(run, report));
|
|
548
|
+
peer.onClose(() => {
|
|
549
|
+
if (this.peers.get(runId) === peer)
|
|
550
|
+
this.peers.delete(runId);
|
|
551
|
+
});
|
|
552
|
+
peer.sendJob(run.job);
|
|
553
|
+
this.log.info('a bot opened its channel', { runId, state: run.state });
|
|
554
|
+
if (run.state === 'leaving') {
|
|
555
|
+
void peer.command({ type: 'command', id: randomUUID(), name: 'leave', reason: run.leaveReason }).then((ack) => {
|
|
556
|
+
if (!ack.ok)
|
|
557
|
+
this.log.warn('a bot did not acknowledge the leave it reconnected to', { runId });
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
onReport(run, report) {
|
|
562
|
+
if (this.stopped || this.runs.get(run.job.runId) !== run)
|
|
563
|
+
return;
|
|
564
|
+
run.reportSeq = Math.max(run.reportSeq, report.seq);
|
|
565
|
+
if (report.event === 'done' || report.event === 'crashed')
|
|
566
|
+
run.finalReported = true;
|
|
567
|
+
this.runs.persist(run);
|
|
568
|
+
if (report.event === 'error' && report.data?.message === NEEDS_REAUTH_MARKER && run.identity)
|
|
569
|
+
this.markNeedsReauth(run.identity);
|
|
570
|
+
void this.enqueue(run, report);
|
|
571
|
+
}
|
|
572
|
+
/** The bot found its identity signed out or challenged: the identity's folder gets the marker, and the identity leaves the pool. */
|
|
573
|
+
markNeedsReauth(identity) {
|
|
574
|
+
const root = this.policy.profilesRoot;
|
|
575
|
+
if (!root)
|
|
576
|
+
return;
|
|
577
|
+
try {
|
|
578
|
+
writeFileSync(join(root, identity, NEEDS_REAUTH_MARKER), `${new Date(this.now()).toISOString()}\n`, { mode: 0o600 });
|
|
579
|
+
this.log.warn('an identity needs signing in again; it leaves the pool', { identity });
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
this.log.error('could not mark an identity for signing in again', { identity, error: describe(err) });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/** Reports of one run go to the product one at a time, in the order they came, so a `done` never overtakes the `left` before it. */
|
|
586
|
+
enqueue(run, report) {
|
|
587
|
+
const runId = run.job.runId;
|
|
588
|
+
const next = (this.forwarding.get(runId) ?? Promise.resolve())
|
|
589
|
+
.then(() => this.forward(run, report))
|
|
590
|
+
.catch((err) => this.log.error('a report was not forwarded', { runId, seq: report.seq, event: report.event, error: describe(err) }));
|
|
591
|
+
this.forwarding.set(runId, next);
|
|
592
|
+
void next.then(() => {
|
|
593
|
+
if (this.forwarding.get(runId) === next)
|
|
594
|
+
this.forwarding.delete(runId);
|
|
595
|
+
});
|
|
596
|
+
return next;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Reports go to the product with the run's own token; a failed post is tried three times in all, then logged by run
|
|
600
|
+
* id, sequence and event (never content). A refusal (a 4xx but 429) is not tried again.
|
|
601
|
+
*/
|
|
602
|
+
async forward(run, report) {
|
|
603
|
+
const runId = run.job.runId;
|
|
604
|
+
const post = { ...report, runId, workerId: this.options.workerId };
|
|
605
|
+
for (let attempt = 1; attempt <= REPORT_ATTEMPTS; attempt++) {
|
|
606
|
+
if (this.stopped)
|
|
607
|
+
return;
|
|
608
|
+
try {
|
|
609
|
+
const res = await this.fetch(run.reportUrl, { method: 'POST', headers: { authorization: `Bearer ${run.workerToken}`, 'content-type': 'application/json' }, body: JSON.stringify(post), redirect: 'error', signal: AbortSignal.timeout(REPORT_TIMEOUT_MS) });
|
|
610
|
+
await res.body?.cancel().catch(() => undefined);
|
|
611
|
+
if (res.ok)
|
|
612
|
+
return;
|
|
613
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
|
|
614
|
+
this.log.warn('the product refused a report', { runId, seq: report.seq, event: report.event, status: res.status });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
this.log.warn('a report post failed', { runId, seq: report.seq, event: report.event, attempt, status: res.status });
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
this.log.warn('a report post failed', { runId, seq: report.seq, event: report.event, attempt, error: describe(err) });
|
|
621
|
+
}
|
|
622
|
+
if (attempt < REPORT_ATTEMPTS)
|
|
623
|
+
await new Promise((r) => setTimeout(r, attempt * 1_000));
|
|
624
|
+
}
|
|
625
|
+
this.log.error('a report was not delivered', { runId, seq: report.seq, event: report.event });
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* The identity for a new Meet run: the first folder under the profiles root (in name order, `notes2` before `notes10`)
|
|
629
|
+
* that its provisioning marked, that is not marked for signing in again, and that no live run holds: one run per
|
|
630
|
+
* identity.
|
|
631
|
+
*/
|
|
632
|
+
pickIdentity(root) {
|
|
633
|
+
const held = new Set(this.runs.values().filter((r) => r.state !== 'exited' && r.identity).map((r) => r.identity));
|
|
634
|
+
let names;
|
|
635
|
+
try {
|
|
636
|
+
names = readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
this.log.error('the identity pool cannot be read', { error: describe(err) });
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
names.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
643
|
+
return names.find((name) => !held.has(name) && existsSync(join(root, name, PROVISIONED_MARKER)) && !existsSync(join(root, name, NEEDS_REAUTH_MARKER)));
|
|
644
|
+
}
|
|
645
|
+
/** The run's token as one line on the container's stdin, which is then closed: the bot reads it to the end before anything else. */
|
|
646
|
+
async writeStdin(containerId, line) {
|
|
647
|
+
const stream = await this.options.docker.getContainer(containerId).attach({ stream: true, stdin: true, hijack: true });
|
|
648
|
+
stream.on('error', (err) => this.log.warn('the stdin stream of a bot container failed', { error: describe(err) }));
|
|
649
|
+
await new Promise((resolve, reject) => stream.write(line, (err) => (err ? reject(err) : resolve())));
|
|
650
|
+
stream.end();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
//# sourceMappingURL=server.js.map
|