@livekit/agents 0.1.0 → 0.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.
Files changed (155) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +47 -0
  3. package/LICENSE +201 -0
  4. package/dist/audio.d.ts +9 -0
  5. package/dist/audio.d.ts.map +1 -0
  6. package/dist/audio.js +54 -0
  7. package/dist/audio.js.map +1 -0
  8. package/dist/cli.d.ts +12 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +102 -19
  11. package/dist/cli.js.map +1 -1
  12. package/dist/generator.d.ts +17 -6
  13. package/dist/generator.d.ts.map +1 -1
  14. package/dist/generator.js +20 -3
  15. package/dist/generator.js.map +1 -1
  16. package/dist/http_server.d.ts +1 -1
  17. package/dist/http_server.d.ts.map +1 -1
  18. package/dist/http_server.js +5 -3
  19. package/dist/http_server.js.map +1 -1
  20. package/dist/index.d.ts +14 -3
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +14 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/ipc/job_executor.d.ts +19 -0
  25. package/dist/ipc/job_executor.d.ts.map +1 -0
  26. package/dist/ipc/job_executor.js +8 -0
  27. package/dist/ipc/job_executor.js.map +1 -0
  28. package/dist/ipc/job_main.d.ts +7 -4
  29. package/dist/ipc/job_main.d.ts.map +1 -1
  30. package/dist/ipc/job_main.js +102 -59
  31. package/dist/ipc/job_main.js.map +1 -1
  32. package/dist/ipc/message.d.ts +41 -0
  33. package/dist/ipc/message.d.ts.map +1 -0
  34. package/dist/ipc/message.js +2 -0
  35. package/dist/ipc/message.js.map +1 -0
  36. package/dist/ipc/proc_job_executor.d.ts +15 -0
  37. package/dist/ipc/proc_job_executor.d.ts.map +1 -0
  38. package/dist/ipc/proc_job_executor.js +150 -0
  39. package/dist/ipc/proc_job_executor.js.map +1 -0
  40. package/dist/ipc/proc_pool.d.ts +26 -0
  41. package/dist/ipc/proc_pool.d.ts.map +1 -0
  42. package/dist/ipc/proc_pool.js +83 -0
  43. package/dist/ipc/proc_pool.js.map +1 -0
  44. package/dist/job.d.ts +100 -0
  45. package/dist/job.d.ts.map +1 -0
  46. package/dist/job.js +213 -0
  47. package/dist/job.js.map +1 -0
  48. package/dist/llm/function_context.d.ts +20 -0
  49. package/dist/llm/function_context.d.ts.map +1 -0
  50. package/dist/llm/function_context.js +37 -0
  51. package/dist/llm/function_context.js.map +1 -0
  52. package/dist/llm/index.d.ts +3 -0
  53. package/dist/llm/index.d.ts.map +1 -0
  54. package/dist/llm/index.js +6 -0
  55. package/dist/llm/index.js.map +1 -0
  56. package/dist/log.d.ts +12 -1
  57. package/dist/log.d.ts.map +1 -1
  58. package/dist/log.js +28 -11
  59. package/dist/log.js.map +1 -1
  60. package/dist/multimodal/agent_playout.d.ts +34 -0
  61. package/dist/multimodal/agent_playout.d.ts.map +1 -0
  62. package/dist/multimodal/agent_playout.js +221 -0
  63. package/dist/multimodal/agent_playout.js.map +1 -0
  64. package/dist/multimodal/index.d.ts +3 -0
  65. package/dist/multimodal/index.d.ts.map +1 -0
  66. package/dist/multimodal/index.js +6 -0
  67. package/dist/multimodal/index.js.map +1 -0
  68. package/dist/multimodal/multimodal_agent.d.ts +47 -0
  69. package/dist/multimodal/multimodal_agent.d.ts.map +1 -0
  70. package/dist/multimodal/multimodal_agent.js +331 -0
  71. package/dist/multimodal/multimodal_agent.js.map +1 -0
  72. package/dist/plugin.js +20 -7
  73. package/dist/plugin.js.map +1 -1
  74. package/dist/stt/index.d.ts +1 -1
  75. package/dist/stt/index.d.ts.map +1 -1
  76. package/dist/stt/index.js.map +1 -1
  77. package/dist/stt/stream_adapter.d.ts +2 -11
  78. package/dist/stt/stream_adapter.d.ts.map +1 -1
  79. package/dist/stt/stream_adapter.js +47 -33
  80. package/dist/stt/stream_adapter.js.map +1 -1
  81. package/dist/stt/stt.d.ts +27 -0
  82. package/dist/stt/stt.d.ts.map +1 -1
  83. package/dist/stt/stt.js +32 -5
  84. package/dist/stt/stt.js.map +1 -1
  85. package/dist/transcription.d.ts +22 -0
  86. package/dist/transcription.d.ts.map +1 -0
  87. package/dist/transcription.js +111 -0
  88. package/dist/transcription.js.map +1 -0
  89. package/dist/tts/stream_adapter.d.ts +4 -11
  90. package/dist/tts/stream_adapter.d.ts.map +1 -1
  91. package/dist/tts/stream_adapter.js +66 -32
  92. package/dist/tts/stream_adapter.js.map +1 -1
  93. package/dist/tts/tts.d.ts +10 -0
  94. package/dist/tts/tts.d.ts.map +1 -1
  95. package/dist/tts/tts.js +48 -7
  96. package/dist/tts/tts.js.map +1 -1
  97. package/dist/utils.d.ts +59 -0
  98. package/dist/utils.d.ts.map +1 -1
  99. package/dist/utils.js +212 -6
  100. package/dist/utils.js.map +1 -1
  101. package/dist/vad.d.ts +29 -0
  102. package/dist/vad.d.ts.map +1 -1
  103. package/dist/vad.js.map +1 -1
  104. package/dist/worker.d.ts +69 -50
  105. package/dist/worker.d.ts.map +1 -1
  106. package/dist/worker.js +414 -213
  107. package/dist/worker.js.map +1 -1
  108. package/package.json +12 -10
  109. package/src/audio.ts +62 -0
  110. package/src/cli.ts +108 -20
  111. package/src/generator.ts +27 -7
  112. package/src/http_server.ts +5 -0
  113. package/src/index.ts +15 -3
  114. package/src/ipc/job_executor.ts +25 -0
  115. package/src/ipc/job_main.ts +141 -61
  116. package/src/ipc/message.ts +39 -0
  117. package/src/ipc/proc_job_executor.ts +162 -0
  118. package/src/ipc/proc_pool.ts +109 -0
  119. package/src/job.ts +278 -0
  120. package/src/llm/function_context.ts +61 -0
  121. package/src/llm/index.ts +11 -0
  122. package/src/log.ts +40 -8
  123. package/src/multimodal/agent_playout.ts +254 -0
  124. package/src/multimodal/index.ts +5 -0
  125. package/src/multimodal/multimodal_agent.ts +428 -0
  126. package/src/stt/index.ts +1 -1
  127. package/src/stt/stream_adapter.ts +32 -32
  128. package/src/stt/stt.ts +27 -0
  129. package/src/transcription.ts +128 -0
  130. package/src/tts/stream_adapter.ts +32 -31
  131. package/src/tts/tts.ts +10 -0
  132. package/src/utils.ts +257 -3
  133. package/src/vad.ts +29 -0
  134. package/src/worker.ts +465 -172
  135. package/tsconfig.json +7 -1
  136. package/dist/ipc/job_process.d.ts +0 -22
  137. package/dist/ipc/job_process.d.ts.map +0 -1
  138. package/dist/ipc/job_process.js +0 -73
  139. package/dist/ipc/job_process.js.map +0 -1
  140. package/dist/ipc/protocol.d.ts +0 -40
  141. package/dist/ipc/protocol.d.ts.map +0 -1
  142. package/dist/ipc/protocol.js +0 -14
  143. package/dist/ipc/protocol.js.map +0 -1
  144. package/dist/job_context.d.ts +0 -16
  145. package/dist/job_context.d.ts.map +0 -1
  146. package/dist/job_context.js +0 -31
  147. package/dist/job_context.js.map +0 -1
  148. package/dist/job_request.d.ts +0 -42
  149. package/dist/job_request.d.ts.map +0 -1
  150. package/dist/job_request.js +0 -79
  151. package/dist/job_request.js.map +0 -1
  152. package/src/ipc/job_process.ts +0 -96
  153. package/src/ipc/protocol.ts +0 -51
  154. package/src/job_context.ts +0 -49
  155. package/src/job_request.ts +0 -118
package/src/worker.ts CHANGED
@@ -1,45 +1,117 @@
1
1
  // SPDX-FileCopyrightText: 2024 LiveKit, Inc.
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
+ import type {
5
+ JobAssignment,
6
+ JobTermination,
7
+ ParticipantInfo,
8
+ TrackSource,
9
+ } from '@livekit/protocol';
4
10
  import {
5
11
  type AvailabilityRequest,
6
- type Job,
7
- type JobAssignment,
8
12
  JobType,
9
13
  ParticipantPermission,
10
14
  ServerMessage,
11
15
  WorkerMessage,
16
+ WorkerStatus,
12
17
  } from '@livekit/protocol';
13
18
  import { EventEmitter } from 'events';
14
- import { AccessToken } from 'livekit-server-sdk';
19
+ import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';
15
20
  import os from 'os';
16
21
  import { WebSocket } from 'ws';
17
22
  import { HTTPServer } from './http_server.js';
18
- import { JobProcess } from './ipc/job_process.js';
19
- import { type AvailRes, JobRequest } from './job_request.js';
20
- import type { AcceptData } from './job_request.js';
23
+ import { ProcPool } from './ipc/proc_pool.js';
24
+ import type { JobAcceptArguments, JobProcess, RunningJobInfo } from './job.js';
25
+ import { JobRequest } from './job.js';
21
26
  import { log } from './log.js';
27
+ import { Future } from './utils.js';
22
28
  import { version } from './version.js';
23
29
 
24
30
  const MAX_RECONNECT_ATTEMPTS = 10;
25
- const ASSIGNMENT_TIMEOUT = 15 * 1000;
26
- const LOAD_INTERVAL = 5 * 1000;
27
-
28
- const cpuLoad = (): number =>
29
- (os
30
- .cpus()
31
- .reduce(
32
- (acc, x) => acc + x.times.idle / Object.values(x.times).reduce((acc, x) => acc + x, 0),
33
- 0,
34
- ) /
35
- os.cpus().length) *
36
- 100;
37
-
38
- class WorkerPermissions {
31
+ const ASSIGNMENT_TIMEOUT = 7.5 * 1000;
32
+ const UPDATE_LOAD_INTERVAL = 2.5 * 1000;
33
+
34
+ class Default {
35
+ static loadThreshold(production: boolean): number {
36
+ if (production) {
37
+ return 0.65;
38
+ } else {
39
+ return Infinity;
40
+ }
41
+ }
42
+
43
+ static numIdleProcesses(production: boolean): number {
44
+ if (production) {
45
+ return 3;
46
+ } else {
47
+ return 0;
48
+ }
49
+ }
50
+
51
+ static port(production: boolean): number {
52
+ if (production) {
53
+ return 8081;
54
+ } else {
55
+ return 0;
56
+ }
57
+ }
58
+ }
59
+
60
+ /** Necessary credentials not provided and not found in an appropriate environmental variable. */
61
+ export class MissingCredentialsError extends Error {
62
+ constructor(msg?: string) {
63
+ super(msg);
64
+ Object.setPrototypeOf(this, new.target.prototype);
65
+ }
66
+ }
67
+
68
+ /** Worker did not run as expected. */
69
+ export class WorkerError extends Error {
70
+ constructor(msg?: string) {
71
+ super(msg);
72
+ Object.setPrototypeOf(this, new.target.prototype);
73
+ }
74
+ }
75
+
76
+ /** @internal */
77
+ export const defaultInitializeProcessFunc = (_: JobProcess) => _;
78
+ const defaultRequestFunc = async (ctx: JobRequest) => {
79
+ await ctx.accept();
80
+ };
81
+ const defaultCpuLoad = async (): Promise<number> => {
82
+ return new Promise((resolve) => {
83
+ const cpus1 = os.cpus();
84
+
85
+ setTimeout(() => {
86
+ const cpus2 = os.cpus();
87
+
88
+ let idle = 0;
89
+ let total = 0;
90
+
91
+ for (let i = 0; i < cpus1.length; i++) {
92
+ const cpu1 = cpus1[i].times;
93
+ const cpu2 = cpus2[i].times;
94
+
95
+ idle += cpu2.idle - cpu1.idle;
96
+
97
+ const total1 = Object.values(cpu1).reduce((acc, i) => acc + i, 0);
98
+ const total2 = Object.values(cpu2).reduce((acc, i) => acc + i, 0);
99
+
100
+ total += total2 - total1;
101
+ }
102
+
103
+ resolve(+(1 - idle / total).toFixed(2));
104
+ }, UPDATE_LOAD_INTERVAL);
105
+ });
106
+ };
107
+
108
+ /** Participant permissions to pass to every agent spun up by this worker. */
109
+ export class WorkerPermissions {
39
110
  canPublish: boolean;
40
111
  canSubscribe: boolean;
41
112
  canPublishData: boolean;
42
113
  canUpdateMetadata: boolean;
114
+ canPublishSources: TrackSource[];
43
115
  hidden: boolean;
44
116
 
45
117
  constructor(
@@ -47,21 +119,37 @@ class WorkerPermissions {
47
119
  canSubscribe = true,
48
120
  canPublishData = true,
49
121
  canUpdateMetadata = true,
122
+ canPublishSources: TrackSource[] = [],
50
123
  hidden = false,
51
124
  ) {
52
125
  this.canPublish = canPublish;
53
126
  this.canSubscribe = canSubscribe;
54
127
  this.canPublishData = canPublishData;
55
128
  this.canUpdateMetadata = canUpdateMetadata;
129
+ this.canPublishSources = canPublishSources;
56
130
  this.hidden = hidden;
57
131
  }
58
132
  }
59
133
 
134
+ /**
135
+ * Data class describing worker behaviour.
136
+ *
137
+ * @remarks
138
+ * The Agents framework provides sane worker defaults, and works out-of-the-box with no tweaking
139
+ * necessary. The only mandatory parameter is `agent`, which points to the entry function.
140
+ *
141
+ * This class is mostly useful in conjunction with {@link cli.runApp}.
142
+ */
60
143
  export class WorkerOptions {
61
- requestFunc: (arg: JobRequest) => Promise<void>;
62
- cpuLoadFunc: () => number;
63
- namespace: string;
144
+ agent: string;
145
+ requestFunc: (job: JobRequest) => Promise<void>;
146
+ loadFunc: () => Promise<number>;
147
+ loadThreshold: number;
148
+ numIdleProcesses: number;
149
+ shutdownProcessTimeout: number;
150
+ initializeProcessTimeout: number;
64
151
  permissions: WorkerPermissions;
152
+ agentName: string;
65
153
  workerType: JobType;
66
154
  maxRetry: number;
67
155
  wsURL: string;
@@ -69,24 +157,45 @@ export class WorkerOptions {
69
157
  apiSecret?: string;
70
158
  host: string;
71
159
  port: number;
160
+ logLevel: string;
161
+ production: boolean;
72
162
 
163
+ /** @param options */
73
164
  constructor({
74
- requestFunc,
75
- cpuLoadFunc = cpuLoad,
76
- namespace = 'default',
165
+ agent,
166
+ requestFunc = defaultRequestFunc,
167
+ loadFunc = defaultCpuLoad,
168
+ loadThreshold = undefined,
169
+ numIdleProcesses = undefined,
170
+ shutdownProcessTimeout = 60 * 1000,
171
+ initializeProcessTimeout = 10 * 1000,
77
172
  permissions = new WorkerPermissions(),
78
- workerType = JobType.JT_PUBLISHER,
173
+ agentName = '',
174
+ workerType = JobType.JT_ROOM,
79
175
  maxRetry = MAX_RECONNECT_ATTEMPTS,
80
176
  wsURL = 'ws://localhost:7880',
81
177
  apiKey = undefined,
82
178
  apiSecret = undefined,
83
179
  host = 'localhost',
84
- port = 8081,
180
+ port = undefined,
181
+ logLevel = 'info',
182
+ production = false,
85
183
  }: {
86
- requestFunc: (arg: JobRequest) => Promise<void>;
87
- cpuLoadFunc?: () => number;
88
- namespace?: string;
184
+ /**
185
+ * Path to a file that has {@link Agent} as a default export, dynamically imported later for
186
+ * entrypoint and prewarm functions
187
+ */
188
+ agent: string;
189
+ requestFunc?: (job: JobRequest) => Promise<void>;
190
+ /** Called to determine the current load of the worker. Should return a value between 0 and 1. */
191
+ loadFunc?: () => Promise<number>;
192
+ /** When the load exceeds this threshold, the worker will be marked as unavailable. */
193
+ loadThreshold?: number;
194
+ numIdleProcesses?: number;
195
+ shutdownProcessTimeout?: number;
196
+ initializeProcessTimeout?: number;
89
197
  permissions?: WorkerPermissions;
198
+ agentName?: string;
90
199
  workerType?: JobType;
91
200
  maxRetry?: number;
92
201
  wsURL?: string;
@@ -94,113 +203,146 @@ export class WorkerOptions {
94
203
  apiSecret?: string;
95
204
  host?: string;
96
205
  port?: number;
206
+ logLevel?: string;
207
+ production?: boolean;
97
208
  }) {
209
+ this.agent = agent;
210
+ if (!this.agent) {
211
+ throw new Error('No Agent file was passed to the worker');
212
+ }
98
213
  this.requestFunc = requestFunc;
99
- this.cpuLoadFunc = cpuLoadFunc;
100
- this.namespace = namespace;
214
+ this.loadFunc = loadFunc;
215
+ this.loadThreshold = loadThreshold || Default.loadThreshold(production);
216
+ this.numIdleProcesses = numIdleProcesses || Default.numIdleProcesses(production);
217
+ this.shutdownProcessTimeout = shutdownProcessTimeout;
218
+ this.initializeProcessTimeout = initializeProcessTimeout;
101
219
  this.permissions = permissions;
220
+ this.agentName = agentName;
102
221
  this.workerType = workerType;
103
222
  this.maxRetry = maxRetry;
104
223
  this.wsURL = wsURL;
105
224
  this.apiKey = apiKey;
106
225
  this.apiSecret = apiSecret;
107
226
  this.host = host;
108
- this.port = port;
227
+ this.port = port || Default.port(production);
228
+ this.logLevel = logLevel;
229
+ this.production = production;
109
230
  }
110
231
  }
111
232
 
112
- class ActiveJob {
113
- job: Job;
114
- acceptData: AcceptData;
115
-
116
- constructor(job: Job, acceptData: AcceptData) {
117
- this.job = job;
118
- this.acceptData = acceptData;
119
- }
120
- }
121
-
122
- type AssignmentPair = {
123
- // this string is the JSON string version of the JobAssignment.
124
- // we keep it around to unpack it again in the child, because we can't pass Job directly.
125
- raw: string;
126
- asgn: JobAssignment;
127
- };
128
-
129
233
  class PendingAssignment {
130
- promise = new Promise<AssignmentPair>((resolve) => {
131
- this.resolve = resolve; // oh, JavaScript.
234
+ promise = new Promise<JobAssignment>((resolve) => {
235
+ this.resolve = resolve; // this is how JavaScript lets you resolve promises externally
132
236
  });
133
- resolve(arg: AssignmentPair) {
134
- arg;
237
+ resolve(arg: JobAssignment) {
238
+ arg; // useless call to counteract TypeScript E6133
135
239
  }
136
240
  }
137
241
 
242
+ /**
243
+ * Central orchestrator for all processes and job requests.
244
+ *
245
+ * @remarks
246
+ * For most usecases, Worker should not be initialized or handled directly; you should instead call
247
+ * for its creation through {@link cli.runApp}. This could, however, be useful in situations where
248
+ * you don't have access to a command line, such as a headless program, or one that uses Agents
249
+ * behind a wrapper.
250
+ */
138
251
  export class Worker {
139
- opts: WorkerOptions;
252
+ #opts: WorkerOptions;
253
+ #procPool: ProcPool;
254
+
140
255
  #id = 'unregistered';
141
- session: WebSocket | undefined = undefined;
142
- closed = false;
143
- httpServer: HTTPServer;
144
- logger = log.child({ version });
256
+ #closed = true;
257
+ #draining = false;
258
+ #connecting = false;
259
+ #tasks: Promise<void>[] = [];
260
+ #pending: { [id: string]: PendingAssignment } = {};
261
+ #close = new Future();
262
+
145
263
  event = new EventEmitter();
146
- pending: { [id: string]: { value: PendingAssignment } } = {};
147
- processes: { [id: string]: { proc: JobProcess; activeJob: ActiveJob } } = {};
264
+ #session: WebSocket | undefined = undefined;
265
+ #httpServer: HTTPServer;
266
+ #logger = log().child({ version });
148
267
 
268
+ /* @throws {@link MissingCredentialsError} if URL, API key or API secret are missing */
149
269
  constructor(opts: WorkerOptions) {
150
270
  opts.wsURL = opts.wsURL || process.env.LIVEKIT_URL || '';
151
271
  opts.apiKey = opts.apiKey || process.env.LIVEKIT_API_KEY || '';
152
272
  opts.apiSecret = opts.apiSecret || process.env.LIVEKIT_API_SECRET || '';
153
273
 
154
- this.opts = opts;
155
- this.httpServer = new HTTPServer(opts.host, opts.port);
156
- }
274
+ if (opts.wsURL === '')
275
+ throw new MissingCredentialsError(
276
+ 'URL is required: Set LIVEKIT_URL, run with --url, or pass wsURL in WorkerOptions',
277
+ );
278
+ if (opts.apiKey === '')
279
+ throw new MissingCredentialsError(
280
+ 'API Key is required: Set LIVEKIT_API_KEY, run with --api-key, or pass apiKey in WorkerOptions',
281
+ );
282
+ if (opts.apiSecret === '')
283
+ throw new MissingCredentialsError(
284
+ 'API Secret is required: Set LIVEKIT_API_SECRET, run with --api-secret, or pass apiSecret in WorkerOptions',
285
+ );
157
286
 
158
- get id(): string {
159
- return this.#id;
287
+ this.#procPool = new ProcPool(
288
+ opts.agent,
289
+ opts.numIdleProcesses,
290
+ opts.initializeProcessTimeout,
291
+ opts.shutdownProcessTimeout,
292
+ );
293
+
294
+ this.#opts = opts;
295
+ this.#httpServer = new HTTPServer(opts.host, opts.port);
160
296
  }
161
297
 
298
+ /* @throws {@link WorkerError} if worker failed to connect or already running */
162
299
  async run() {
163
- this.logger.info('starting worker');
300
+ if (!this.#closed) {
301
+ throw new WorkerError('worker is already running');
302
+ }
164
303
 
165
- if (this.opts.wsURL === '') throw new Error('--url is required, or set LIVEKIT_URL env var');
166
- if (this.opts.apiKey === '')
167
- throw new Error('--api-key is required, or set LIVEKIT_API_KEY env var');
168
- if (this.opts.apiSecret === '')
169
- throw new Error('--api-secret is required, or set LIVEKIT_API_SECRET env var');
304
+ this.#logger.info('starting worker');
305
+ this.#closed = false;
306
+ this.#procPool.start();
170
307
 
171
308
  const workerWS = async () => {
172
309
  let retries = 0;
173
- while (!this.closed) {
174
- const token = new AccessToken(this.opts.apiKey, this.opts.apiSecret);
175
- token.addGrant({ agent: true });
176
- const jwt = await token.toJwt();
310
+ this.#connecting = true;
177
311
 
178
- const url = new URL(this.opts.wsURL);
312
+ while (!this.#closed) {
313
+ const url = new URL(this.#opts.wsURL);
179
314
  url.protocol = url.protocol.replace('http', 'ws');
180
- this.session = new WebSocket(url + 'agent', {
315
+ const token = new AccessToken(this.#opts.apiKey, this.#opts.apiSecret);
316
+ token.addGrant({ agent: true });
317
+ const jwt = await token.toJwt();
318
+ this.#session = new WebSocket(url + 'agent', {
181
319
  headers: { authorization: 'Bearer ' + jwt },
182
320
  });
183
321
 
184
322
  try {
185
323
  await new Promise((resolve, reject) => {
186
- this.session!.on('open', resolve);
187
- this.session!.on('error', (error) => reject(error));
188
- this.session!.on('close', (code) => reject(`WebSocket returned ${code}`));
324
+ this.#session!.on('open', resolve);
325
+ this.#session!.on('error', (error) => reject(error));
326
+ this.#session!.on('close', (code) => reject(`WebSocket returned ${code}`));
189
327
  });
190
328
 
191
- this.runWS(this.session!);
329
+ retries = 0;
330
+ this.#logger.debug('connected to LiveKit server');
331
+ this.#runWS(this.#session);
192
332
  return;
193
333
  } catch (e) {
194
- if (this.closed) return;
195
- if (retries >= this.opts.maxRetry) {
196
- throw new Error(`failed to connect to LiveKit server after ${retries} attempts: ${e}`);
334
+ if (this.#closed) return;
335
+ if (retries >= this.#opts.maxRetry) {
336
+ throw new WorkerError(
337
+ `failed to connect to LiveKit server after ${retries} attempts: ${e}`,
338
+ );
197
339
  }
198
340
 
199
341
  retries++;
200
342
  const delay = Math.min(retries * 2, 10);
201
343
 
202
- this.logger.warn(
203
- `failed to connect to LiveKit server, retrying in ${delay} seconds: ${e} (${retries}/${this.opts.maxRetry})`,
344
+ this.#logger.warn(
345
+ `failed to connect to LiveKit server, retrying in ${delay} seconds: ${e} (${retries}/${this.#opts.maxRetry})`,
204
346
  );
205
347
 
206
348
  await new Promise((resolve) => setTimeout(resolve, delay * 1000));
@@ -208,24 +350,92 @@ export class Worker {
208
350
  }
209
351
  };
210
352
 
211
- await Promise.all([workerWS(), this.httpServer.run()]);
353
+ await Promise.all([workerWS(), this.#httpServer.run()]);
354
+ this.#close.resolve();
212
355
  }
213
356
 
214
- startProcess(job: Job, acceptData: AcceptData, raw: string) {
215
- const proc = new JobProcess(job, acceptData, raw, this.opts.wsURL);
216
- this.processes[job.id] = { proc, activeJob: new ActiveJob(job, acceptData) };
217
- proc
218
- .run()
219
- .catch((e) => {
220
- proc.logger.error(`error running job process ${proc.job.id}: ${e}`);
221
- })
222
- .finally(() => {
223
- proc.clear();
224
- delete this.processes[job.id];
225
- });
357
+ get id(): string {
358
+ return this.#id;
359
+ }
360
+
361
+ get activeJobs(): RunningJobInfo[] {
362
+ return this.#procPool.processes
363
+ .filter((proc) => proc.runningJob)
364
+ .map((proc) => proc.runningJob!);
365
+ }
366
+
367
+ /* @throws {@link WorkerError} if worker did not drain in time */
368
+ async drain(timeout?: number) {
369
+ if (this.#draining) {
370
+ return;
371
+ }
372
+
373
+ this.#logger.info('draining worker');
374
+ this.#draining = true;
375
+
376
+ this.event.emit(
377
+ 'worker_msg',
378
+ new WorkerMessage({
379
+ message: {
380
+ case: 'updateWorker',
381
+ value: {
382
+ status: WorkerStatus.WS_FULL,
383
+ },
384
+ },
385
+ }),
386
+ );
387
+
388
+ const joinJobs = async () => {
389
+ return Promise.all(
390
+ this.#procPool.processes.map((proc) => {
391
+ if (!proc.runningJob) {
392
+ proc.close();
393
+ }
394
+ return proc.join();
395
+ }),
396
+ );
397
+ };
398
+
399
+ const timer = setTimeout(() => {
400
+ throw new WorkerError('timed out draining');
401
+ }, timeout);
402
+ if (timeout === undefined) clearTimeout(timer);
403
+ await joinJobs().then(() => {
404
+ clearTimeout(timer);
405
+ });
226
406
  }
227
407
 
228
- runWS(ws: WebSocket) {
408
+ async simulateJob(roomName: string, participantIdentity?: string) {
409
+ const client = new RoomServiceClient(this.#opts.wsURL, this.#opts.apiKey, this.#opts.apiSecret);
410
+ const room = await client.createRoom({ name: roomName });
411
+ let participant: ParticipantInfo | undefined = undefined;
412
+ if (participantIdentity) {
413
+ try {
414
+ participant = await client.getParticipant(roomName, participantIdentity);
415
+ } catch (e) {
416
+ this.#logger.fatal(
417
+ `participant with identity ${participantIdentity} not found in room ${roomName}`,
418
+ );
419
+ throw e;
420
+ }
421
+ }
422
+
423
+ this.event!.emit(
424
+ 'worker_msg',
425
+ new WorkerMessage({
426
+ message: {
427
+ case: 'simulateJob',
428
+ value: {
429
+ type: JobType.JT_PUBLISHER,
430
+ room,
431
+ participant,
432
+ },
433
+ },
434
+ }),
435
+ );
436
+ }
437
+
438
+ #runWS(ws: WebSocket) {
229
439
  let closingWS = false;
230
440
 
231
441
  const send = (msg: WorkerMessage) => {
@@ -239,44 +449,62 @@ export class Worker {
239
449
 
240
450
  ws.addEventListener('close', () => {
241
451
  closingWS = true;
242
- this.logger.error('worker connection closed unexpectedly');
452
+ this.#logger.error('worker connection closed unexpectedly');
243
453
  this.close();
244
454
  });
245
455
 
246
456
  ws.addEventListener('message', (event) => {
247
457
  if (event.type !== 'message') {
248
- this.logger.warn('unexpected message type: ' + event.type);
458
+ this.#logger.warn('unexpected message type: ' + event.type);
249
459
  return;
250
460
  }
251
461
 
252
462
  const msg = new ServerMessage();
253
463
  msg.fromBinary(event.data as Uint8Array);
464
+
465
+ // register is the only valid first message, and it is only valid as the
466
+ // first message
467
+ if (this.#connecting && msg.message.case !== 'register') {
468
+ throw new WorkerError('expected register response as first message');
469
+ }
470
+
254
471
  switch (msg.message.case) {
255
472
  case 'register': {
256
473
  this.#id = msg.message.value.workerId;
257
- log
474
+ this.#logger
258
475
  .child({ id: this.id, server_info: msg.message.value.serverInfo })
259
476
  .info('registered worker');
477
+ this.event.emit(
478
+ 'worker_registered',
479
+ msg.message.value.workerId,
480
+ msg.message.value.serverInfo!,
481
+ );
482
+ this.#connecting = false;
260
483
  break;
261
484
  }
262
485
  case 'availability': {
263
- this.availability(msg.message.value);
486
+ const task = this.#availability(msg.message.value);
487
+ this.#tasks.push(task);
488
+ task.finally(() => this.#tasks.splice(this.#tasks.indexOf(task)));
264
489
  break;
265
490
  }
266
491
  case 'assignment': {
267
492
  const job = msg.message.value.job!;
268
- if (job.id in this.pending) {
269
- const task = this.pending[job.id];
270
- delete this.pending[job.id];
271
- task.value.resolve({
272
- asgn: msg.message.value,
273
- raw: msg.toJsonString(),
274
- });
493
+ if (job.id in this.#pending) {
494
+ const task = this.#pending[job.id];
495
+ delete this.#pending[job.id];
496
+ task.resolve(msg.message.value);
275
497
  } else {
276
- log.child({ job }).warn('received assignment for unknown job ' + job.id);
498
+ this.#logger.child({ job }).warn('received assignment for unknown job ' + job.id);
277
499
  }
278
500
  break;
279
501
  }
502
+ case 'termination': {
503
+ const task = this.#termination(msg.message.value);
504
+ this.#tasks.push(task);
505
+ task.finally(() => this.#tasks.splice(this.#tasks.indexOf(task)));
506
+ break;
507
+ }
280
508
  }
281
509
  });
282
510
 
@@ -286,13 +514,14 @@ export class Worker {
286
514
  message: {
287
515
  case: 'register',
288
516
  value: {
289
- type: this.opts.workerType,
290
- namespace: this.opts.namespace,
517
+ type: this.#opts.workerType,
518
+ agentName: this.#opts.agentName,
291
519
  allowedPermissions: new ParticipantPermission({
292
- canPublish: this.opts.permissions.canPublish,
293
- canSubscribe: this.opts.permissions.canSubscribe,
294
- canPublishData: this.opts.permissions.canPublishData,
295
- hidden: this.opts.permissions.hidden,
520
+ canPublish: this.#opts.permissions.canPublish,
521
+ canSubscribe: this.#opts.permissions.canSubscribe,
522
+ canPublishData: this.#opts.permissions.canPublishData,
523
+ canUpdateMetadata: this.#opts.permissions.canUpdateMetadata,
524
+ hidden: this.#opts.permissions.hidden,
296
525
  agent: true,
297
526
  }),
298
527
  version,
@@ -301,84 +530,148 @@ export class Worker {
301
530
  }),
302
531
  );
303
532
 
533
+ let currentStatus = WorkerStatus.WS_AVAILABLE;
304
534
  const loadMonitor = setInterval(() => {
305
535
  if (closingWS) clearInterval(loadMonitor);
536
+
537
+ const oldStatus = currentStatus;
538
+ this.#opts.loadFunc().then((currentLoad: number) => {
539
+ const isFull = currentLoad >= this.#opts.loadThreshold;
540
+ const currentlyAvailable = !isFull;
541
+ currentStatus = currentlyAvailable ? WorkerStatus.WS_AVAILABLE : WorkerStatus.WS_FULL;
542
+
543
+ if (oldStatus != currentStatus) {
544
+ const extra = { load: currentLoad, loadThreshold: this.#opts.loadThreshold };
545
+ if (isFull) {
546
+ this.#logger.child(extra).info('worker is at full capacity, marking as unavailable');
547
+ } else {
548
+ this.#logger.child(extra).info('worker is below capacity, marking as available');
549
+ }
550
+ }
551
+
552
+ this.event.emit(
553
+ 'worker_msg',
554
+ new WorkerMessage({
555
+ message: {
556
+ case: 'updateWorker',
557
+ value: {
558
+ load: currentLoad,
559
+ status: currentStatus,
560
+ },
561
+ },
562
+ }),
563
+ );
564
+ });
565
+ }, UPDATE_LOAD_INTERVAL);
566
+ }
567
+
568
+ async #availability(msg: AvailabilityRequest) {
569
+ let answered = false;
570
+
571
+ const onReject = async () => {
572
+ answered = true;
306
573
  this.event.emit(
307
574
  'worker_msg',
308
575
  new WorkerMessage({
309
576
  message: {
310
- case: 'updateWorker',
577
+ case: 'availability',
311
578
  value: {
312
- load: cpuLoad(),
579
+ jobId: msg.job!.id,
580
+ available: false,
313
581
  },
314
582
  },
315
583
  }),
316
584
  );
317
- }, LOAD_INTERVAL);
318
- }
585
+ };
319
586
 
320
- async availability(msg: AvailabilityRequest) {
321
- const tx = new EventEmitter();
322
- const req = new JobRequest(msg.job!, tx);
587
+ const onAccept = async (args: JobAcceptArguments) => {
588
+ answered = true;
323
589
 
324
- tx.on('recv', async (av: AvailRes) => {
325
- const msg = new WorkerMessage({
326
- message: {
327
- case: 'availability',
328
- value: {
329
- available: av.avail,
330
- jobId: req.id,
331
- participantIdentity: av.data?.identity,
332
- participantName: av.data?.name,
333
- participantMetadata: av.data?.metadata,
590
+ this.event.emit(
591
+ 'worker_msg',
592
+ new WorkerMessage({
593
+ message: {
594
+ case: 'availability',
595
+ value: {
596
+ jobId: msg.job!.id,
597
+ available: true,
598
+ participantIdentity: args.identity,
599
+ participantName: args.name,
600
+ participantMetadata: args.metadata,
601
+ },
334
602
  },
335
- },
336
- });
337
-
338
- this.pending[req.id] = { value: new PendingAssignment() };
339
- this.event.emit('worker_msg', msg);
340
- if (!av.avail) return;
603
+ }),
604
+ );
341
605
 
606
+ this.#pending[req.id] = new PendingAssignment();
342
607
  const timer = setTimeout(() => {
343
- log.child({ req }).warn(`assignment for job ${req.id} timed out`);
608
+ this.#logger.child({ req }).warn(`assignment for job ${req.id} timed out`);
344
609
  return;
345
610
  }, ASSIGNMENT_TIMEOUT);
346
- this.pending[req.id].value.promise.then(({ asgn, raw }) => {
611
+ const asgn = await this.#pending[req.id].promise.then(async (asgn) => {
347
612
  clearTimeout(timer);
348
- this.startProcess(asgn!.job!, av.data!, raw);
613
+ return asgn;
349
614
  });
350
- });
351
615
 
352
- try {
353
- this.opts.requestFunc(req);
354
- } catch (e) {
355
- log.child({ req }).error(`user request handler for job ${req.id} failed`);
356
- } finally {
357
- if (!req.answered) {
358
- log.child({ req }).error(`no answer for job ${req.id}, automatically rejecting the job`);
359
- this.event.emit(
360
- 'worker_msg',
361
- new WorkerMessage({
362
- message: {
363
- case: 'availability',
364
- value: {
365
- available: false,
366
- },
367
- },
368
- }),
369
- );
616
+ await this.#procPool.launchJob({
617
+ acceptArguments: args,
618
+ job: msg.job!,
619
+ url: asgn.url || this.#opts.wsURL,
620
+ token: asgn.token,
621
+ });
622
+ };
623
+
624
+ const req = new JobRequest(msg.job!, onReject, onAccept);
625
+ this.#logger
626
+ .child({ job: msg.job, resuming: msg.resuming, agentName: this.#opts.agentName })
627
+ .info('received job request');
628
+
629
+ const jobRequestTask = async () => {
630
+ try {
631
+ await this.#opts.requestFunc(req);
632
+ } catch (e) {
633
+ this.#logger
634
+ .child({ job: msg.job, resuming: msg.resuming, agentName: this.#opts.agentName })
635
+ .info('jobRequestFunc failed');
636
+ await onReject();
370
637
  }
638
+
639
+ if (!answered) {
640
+ this.#logger
641
+ .child({ job: msg.job, resuming: msg.resuming, agentName: this.#opts.agentName })
642
+ .info('no answer was given inside the jobRequestFunc, automatically rejecting the job');
643
+ }
644
+ };
645
+
646
+ const task = jobRequestTask();
647
+ this.#tasks.push(task);
648
+ task.finally(() => this.#tasks.splice(this.#tasks.indexOf(task)));
649
+ }
650
+
651
+ async #termination(msg: JobTermination) {
652
+ const proc = this.#procPool.getByJobId(msg.jobId);
653
+ if (proc === null) {
654
+ // safe to ignore
655
+ return;
371
656
  }
657
+ await proc.close();
372
658
  }
373
659
 
374
660
  async close() {
375
- if (this.closed) return;
376
- this.closed = true;
377
- this.logger.debug('shutting down worker');
378
- await this.httpServer.close();
379
- for await (const value of Object.values(this.processes)) {
380
- await value.proc.close();
661
+ if (this.#closed) {
662
+ await this.#close.await;
663
+ return;
381
664
  }
382
- this.session?.close();
665
+
666
+ this.#logger.info('shutting down worker');
667
+
668
+ this.#closed = true;
669
+
670
+ await this.#procPool.close();
671
+ await this.#httpServer.close();
672
+ await Promise.allSettled(this.#tasks);
673
+
674
+ this.#session?.close();
675
+ await this.#close.await;
383
676
  }
384
677
  }