ag-ai 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.
Files changed (55) hide show
  1. package/README.md +31 -0
  2. package/dist/client/adapters/cluster.d.ts +12 -0
  3. package/dist/client/adapters/cluster.js +567 -0
  4. package/dist/client/adapters/google.d.ts +12 -0
  5. package/dist/client/adapters/google.js +478 -0
  6. package/dist/client/client.d.ts +11 -0
  7. package/dist/client/client.js +192 -0
  8. package/dist/client/index.d.ts +3 -0
  9. package/dist/client/index.js +18 -0
  10. package/dist/client/quota.d.ts +55 -0
  11. package/dist/client/quota.js +148 -0
  12. package/dist/client/types.d.ts +124 -0
  13. package/dist/client/types.js +2 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +18 -0
  16. package/dist/internal/apikey.d.ts +5 -0
  17. package/dist/internal/apikey.js +51 -0
  18. package/dist/internal/array.d.ts +2 -0
  19. package/dist/internal/array.js +6 -0
  20. package/dist/internal/log.d.ts +9 -0
  21. package/dist/internal/log.js +92 -0
  22. package/dist/internal/node-cache.d.ts +71 -0
  23. package/dist/internal/node-cache.js +104 -0
  24. package/dist/internal/retryOnError.d.ts +26 -0
  25. package/dist/internal/retryOnError.js +123 -0
  26. package/dist/internal/string/redact.d.ts +2 -0
  27. package/dist/internal/string/redact.js +94 -0
  28. package/dist/internal/string/truncate.d.ts +8 -0
  29. package/dist/internal/string/truncate.js +16 -0
  30. package/dist/k8s/assets/network-init.sh +52 -0
  31. package/dist/k8s/common.d.ts +35 -0
  32. package/dist/k8s/common.js +56 -0
  33. package/dist/k8s/index.d.ts +9 -0
  34. package/dist/k8s/index.js +33 -0
  35. package/dist/k8s/opencode.d.ts +4 -0
  36. package/dist/k8s/opencode.js +55 -0
  37. package/dist/k8s/runner.d.ts +4 -0
  38. package/dist/k8s/runner.js +247 -0
  39. package/dist/k8s/t3code.d.ts +4 -0
  40. package/dist/k8s/t3code.js +60 -0
  41. package/package.json +56 -0
  42. package/runtime/bridge.mjs +1093 -0
  43. package/runtime/executor/opencode.mjs +430 -0
  44. package/runtime/image/adapter.mjs +213 -0
  45. package/runtime/index.mjs +53 -0
  46. package/runtime/protocol.mjs +384 -0
  47. package/runtime/server.mjs +621 -0
  48. package/runtime/store.mjs +568 -0
  49. package/runtime/telemetry.mjs +137 -0
  50. package/runtime/tests/bridge.test.mjs +554 -0
  51. package/runtime/tests/image-adapter.test.mjs +148 -0
  52. package/runtime/tests/protocol.test.mjs +156 -0
  53. package/runtime/tests/server.test.mjs +231 -0
  54. package/runtime/tests/store.test.mjs +114 -0
  55. package/runtime/tests/telemetry.test.mjs +138 -0
@@ -0,0 +1,1093 @@
1
+ /* eslint-disable no-await-in-loop, no-console */
2
+ /* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
3
+
4
+ import { randomUUID } from 'node:crypto';
5
+ import { mkdir, readFile, realpath, rm, lstat, writeFile, rename } from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ import { createOpenCodeExecutor, DEFAULT_OPENCODE_URL } from './executor/opencode.mjs';
11
+ import { createImageAdapter } from './image/adapter.mjs';
12
+ import { normalizeGenerationRequest } from './protocol.mjs';
13
+ import { createLocalRunner, startGenerationServer } from './server.mjs';
14
+ import { GenerationStore } from './store.mjs';
15
+ import { AnalyticaEvents } from './telemetry.mjs';
16
+
17
+ // Generic placeholder: the queue endpoint must be supplied through AG_AI_QUEUE_URL.
18
+ const DEFAULT_QUEUE_URL = 'https://queue.invalid/api/ai/runner';
19
+ const DEFAULT_TOKEN_FILE = '/var/run/secrets/ag-ai-runner/token';
20
+ const DEFAULT_WORKSPACE = '/workspace';
21
+ const DEFAULT_MAIN_WORKSPACE = '/data/workspace';
22
+ const DEFAULT_GENERATED_IMAGES = '/generated-images';
23
+ const DEFAULT_MAIN_GENERATED_IMAGES = '/data/ag-ai/generated_images';
24
+ const DEFAULT_GENERATION_PORT = 4501;
25
+ const DEFAULT_GENERATION_STORE = '/generation-jobs';
26
+ const DEFAULT_GENERATION_TOKEN_FILE = '/var/run/secrets/ag-ai-generation/token';
27
+ const DEFAULT_ANALYTICA_TOKEN_FILE = '/var/run/secrets/ag-ai-analytica/token';
28
+ const DEFAULT_IMAGE_PROVIDER = 'openai';
29
+ const HEARTBEAT_INTERVAL_MS = 10_000;
30
+ const LEASE_HEARTBEAT_INTERVAL_MS = 5_000;
31
+ const HEALTH_INTERVAL_MS = 30_000;
32
+ const CLAIM_INTERVAL_MS = 5_000;
33
+ const REQUEST_TIMEOUT_MS = 15_000;
34
+ const TURN_TIMEOUT_MS = 30 * 60 * 1000;
35
+ const SHUTDOWN_TIMEOUT_MS = 10_000;
36
+ const MAX_HTTP_JSON_BYTES = 2 * 1024 * 1024;
37
+ const MAX_HTTP_INPUT_BYTES = 64 * 1024 * 1024;
38
+ const MAX_HTTP_ERROR_BYTES = 16 * 1024;
39
+ const MAX_OUTPUT_IMAGE_BYTES = 25 * 1024 * 1024;
40
+ const MAX_OUTPUT_IMAGE_TOTAL_BYTES = 40 * 1024 * 1024;
41
+ const MAX_OUTPUT_IMAGES = 4;
42
+ const MAX_OUTPUT_TEXT_BYTES = 2 * 1024 * 1024;
43
+ const MAX_ID_LENGTH = 160;
44
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
45
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
46
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
47
+
48
+ class UncertainRequestError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = 'UncertainRequestError';
52
+ }
53
+ }
54
+
55
+ class RunnerHttpError extends Error {
56
+ constructor(status, message) {
57
+ super(`runner HTTP ${status}: ${message}`);
58
+ this.name = 'RunnerHttpError';
59
+ this.status = status;
60
+ }
61
+ }
62
+
63
+ class JobError extends Error {
64
+ constructor(message, outcome = 'failed') {
65
+ super(message);
66
+ this.name = 'JobError';
67
+ this.outcome = outcome;
68
+ }
69
+ }
70
+
71
+ const asObject = (value) => (value && typeof value === 'object' ? value : null);
72
+
73
+ const boundedString = (value, maxLength, name) => {
74
+ if (typeof value !== 'string' || value.length > maxLength) {
75
+ throw new JobError(`${name} is missing or too long`);
76
+ }
77
+
78
+ return value;
79
+ };
80
+
81
+ const boundedId = (value, name) => {
82
+ if (
83
+ typeof value !== 'string' ||
84
+ value.length === 0 ||
85
+ value.length > MAX_ID_LENGTH ||
86
+ !SAFE_ID.test(value)
87
+ ) {
88
+ throw new JobError(`${name} is not a safe identifier`);
89
+ }
90
+
91
+ return value;
92
+ };
93
+
94
+ const boundedInteger = (value, name, min, max) => {
95
+ if (!Number.isInteger(value) || value < min || value > max) {
96
+ throw new JobError(`${name} is outside its allowed range`);
97
+ }
98
+
99
+ return value;
100
+ };
101
+
102
+ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
103
+
104
+ const errorMessage = (error) => {
105
+ if (error instanceof Error) {
106
+ return error.message.slice(0, 500);
107
+ }
108
+
109
+ return String(error).slice(0, 500);
110
+ };
111
+
112
+ const redact = (value, secrets = []) => {
113
+ let message = errorMessage(value);
114
+ for (const secret of secrets) {
115
+ if (secret) {
116
+ message = message.split(secret).join('[redacted]');
117
+ }
118
+ }
119
+
120
+ return message.replace(/(bearer\s+)[^\s]+/gi, '$1[redacted]');
121
+ };
122
+
123
+ const parseJson = (bytes, label) => {
124
+ try {
125
+ return JSON.parse(bytes.toString('utf8'));
126
+ } catch {
127
+ throw new Error(`${label} was not valid JSON`);
128
+ }
129
+ };
130
+
131
+ const readResponseBytes = async (response, maximum) => {
132
+ const contentLength = Number(response.headers.get('content-length'));
133
+ if (Number.isFinite(contentLength) && contentLength > maximum) {
134
+ throw new Error(`response exceeds ${maximum} bytes`);
135
+ }
136
+
137
+ if (!response.body) {
138
+ return Buffer.alloc(0);
139
+ }
140
+
141
+ const reader = response.body.getReader();
142
+ const chunks = [];
143
+ let total = 0;
144
+ try {
145
+ for (;;) {
146
+ const { done, value } = await reader.read();
147
+ if (done) {
148
+ break;
149
+ }
150
+
151
+ const chunk = Buffer.from(value);
152
+ total += chunk.length;
153
+ if (total > maximum) {
154
+ await reader.cancel();
155
+ throw new Error(`response exceeds ${maximum} bytes`);
156
+ }
157
+
158
+ chunks.push(chunk);
159
+ }
160
+ } finally {
161
+ reader.releaseLock();
162
+ }
163
+
164
+ return Buffer.concat(chunks, total);
165
+ };
166
+
167
+ const timeoutSignal = (milliseconds) => {
168
+ const controller = new AbortController();
169
+ const timer = setTimeout(() => controller.abort(), milliseconds);
170
+ return { signal: controller.signal, cancel: () => clearTimeout(timer) };
171
+ };
172
+
173
+ const normalizeBaseUrl = (value) => {
174
+ let url;
175
+ try {
176
+ url = new URL(value);
177
+ } catch {
178
+ throw new Error('AG_AI_QUEUE_URL must be an absolute URL');
179
+ }
180
+
181
+ if (url.protocol !== 'https:') {
182
+ throw new Error('AG_AI_QUEUE_URL must be an absolute https URL');
183
+ }
184
+ if (url.username || url.password || url.search || url.hash) {
185
+ throw new Error('AG_AI_QUEUE_URL must not contain credentials, query, or fragment');
186
+ }
187
+
188
+ return url;
189
+ };
190
+
191
+ const urlFor = (baseUrl, suffix) => {
192
+ const base = baseUrl.href.endsWith('/') ? baseUrl.href : `${baseUrl.href}/`;
193
+ return new URL(suffix.replace(/^\/+/, ''), base);
194
+ };
195
+
196
+ const responseErrorMessage = (body) => {
197
+ const text = body.toString('utf8').replace(/\s+/g, ' ').trim();
198
+ return text.slice(0, 500) || 'request failed';
199
+ };
200
+
201
+ const runnerRequestRoute = (suffix) => {
202
+ const pathName = String(suffix)
203
+ .split('?', 1)[0]
204
+ .replace(/^\/+|\/+$/g, '');
205
+ if (pathName === 'heartbeat' || pathName === 'claim') return pathName;
206
+ const match = /^jobs\/[^/]+\/(input|heartbeat|upload|complete|fail)$/.exec(pathName);
207
+ if (!match) return 'unknown';
208
+ return match[1] === 'heartbeat' ? 'lease-heartbeat' : match[1];
209
+ };
210
+
211
+ const JOB_ROUTES = new Set(['input', 'upload', 'complete', 'fail']);
212
+
213
+ const emitRunnerHttpEvent = (analytics, route, ...segments) => {
214
+ if (!JOB_ROUTES.has(route)) return;
215
+ void analytics.emit('runner-http', route, ...segments);
216
+ };
217
+
218
+ class RunnerClient {
219
+ constructor({ baseUrl, token, runnerId, analytics, fetchImpl = globalThis.fetch }) {
220
+ this.baseUrl = normalizeBaseUrl(baseUrl);
221
+ this.token = boundedString(token, 4096, 'runner token');
222
+ if (this.token.length === 0) {
223
+ throw new Error('runner token is empty');
224
+ }
225
+
226
+ this.runnerId = boundedString(runnerId, MAX_ID_LENGTH, 'runner id');
227
+ if (this.runnerId.length === 0) {
228
+ throw new Error('runner id is empty');
229
+ }
230
+ if (typeof fetchImpl !== 'function') {
231
+ throw new Error('runner fetch implementation is required');
232
+ }
233
+ this.analytics = analytics;
234
+ this.fetchImpl = fetchImpl;
235
+ }
236
+
237
+ async request(
238
+ method,
239
+ suffix,
240
+ {
241
+ body,
242
+ leaseToken,
243
+ timeoutMs = REQUEST_TIMEOUT_MS,
244
+ responseLimit = MAX_HTTP_JSON_BYTES,
245
+ uncertainOnNetwork = false,
246
+ } = {},
247
+ ) {
248
+ const timeout = timeoutSignal(timeoutMs);
249
+ const route = runnerRequestRoute(suffix);
250
+ const verb = method.toLowerCase();
251
+ let responseObserved = false;
252
+ emitRunnerHttpEvent(this.analytics, route, verb, 'requested');
253
+ const headers = {
254
+ accept: 'application/json',
255
+ authorization: `Bearer ${this.token}`,
256
+ };
257
+ if (body !== undefined) {
258
+ headers['content-type'] = 'application/json';
259
+ }
260
+ if (leaseToken) {
261
+ headers['x-ai-lease-token'] = leaseToken;
262
+ }
263
+
264
+ const fetchOptions = {
265
+ method,
266
+ headers,
267
+ signal: timeout.signal,
268
+ redirect: 'error',
269
+ };
270
+ if (body !== undefined) {
271
+ fetchOptions.body = JSON.stringify(body);
272
+ }
273
+ try {
274
+ const response = await this.fetchImpl(urlFor(this.baseUrl, suffix), fetchOptions);
275
+ responseObserved = true;
276
+ emitRunnerHttpEvent(this.analytics, route, verb, 'responded', response.status);
277
+ const responseBody = await readResponseBytes(
278
+ response,
279
+ response.ok ? responseLimit : MAX_HTTP_ERROR_BYTES,
280
+ );
281
+ if (!response.ok) {
282
+ throw new RunnerHttpError(
283
+ response.status,
284
+ redact(responseErrorMessage(responseBody), [this.token, leaseToken]),
285
+ );
286
+ }
287
+
288
+ if (responseBody.length === 0) {
289
+ return {};
290
+ }
291
+
292
+ return parseJson(responseBody, 'runner response');
293
+ } catch (error) {
294
+ if (!responseObserved) emitRunnerHttpEvent(this.analytics, route, verb, 'failed');
295
+ if (error instanceof RunnerHttpError) {
296
+ throw error;
297
+ }
298
+ const message = redact(error, [this.token, leaseToken]);
299
+ throw uncertainOnNetwork ? new UncertainRequestError(message) : new Error(message);
300
+ } finally {
301
+ timeout.cancel();
302
+ }
303
+ }
304
+
305
+ async requestBytes(suffix, bytes, { name, contentType, leaseToken } = {}) {
306
+ const timeout = timeoutSignal(REQUEST_TIMEOUT_MS);
307
+ const route = runnerRequestRoute(suffix);
308
+ let responseObserved = false;
309
+ emitRunnerHttpEvent(this.analytics, route, 'post', 'requested');
310
+ const headers = {
311
+ accept: 'application/json',
312
+ authorization: `Bearer ${this.token}`,
313
+ 'content-type': contentType,
314
+ 'x-ai-result-name': name,
315
+ 'x-ai-lease-token': leaseToken,
316
+ };
317
+
318
+ try {
319
+ const response = await this.fetchImpl(urlFor(this.baseUrl, suffix), {
320
+ method: 'POST',
321
+ headers,
322
+ body: bytes,
323
+ signal: timeout.signal,
324
+ redirect: 'error',
325
+ });
326
+ responseObserved = true;
327
+ emitRunnerHttpEvent(this.analytics, route, 'post', 'responded', response.status);
328
+ const responseBody = await readResponseBytes(
329
+ response,
330
+ response.ok ? MAX_HTTP_JSON_BYTES : MAX_HTTP_ERROR_BYTES,
331
+ );
332
+ if (!response.ok) {
333
+ throw new RunnerHttpError(
334
+ response.status,
335
+ redact(responseErrorMessage(responseBody), [this.token, leaseToken]),
336
+ );
337
+ }
338
+
339
+ return parseJson(responseBody, 'runner upload response');
340
+ } catch (error) {
341
+ if (!responseObserved) emitRunnerHttpEvent(this.analytics, route, 'post', 'failed');
342
+ if (error instanceof RunnerHttpError) {
343
+ throw error;
344
+ }
345
+ throw new UncertainRequestError(redact(error, [this.token, leaseToken]));
346
+ } finally {
347
+ timeout.cancel();
348
+ }
349
+ }
350
+
351
+ heartbeat(activeJobs, providerReady, modelReady) {
352
+ return this.request('POST', 'heartbeat', {
353
+ body: {
354
+ runnerId: this.runnerId,
355
+ authenticatedChatgpt: providerReady,
356
+ modelReady,
357
+ activeJobs,
358
+ },
359
+ });
360
+ }
361
+
362
+ claim() {
363
+ return this.request('POST', 'claim', {
364
+ body: { runnerId: this.runnerId },
365
+ uncertainOnNetwork: true,
366
+ });
367
+ }
368
+
369
+ input(jobId, leaseToken) {
370
+ return this.request('GET', `jobs/${encodeURIComponent(jobId)}/input`, {
371
+ leaseToken,
372
+ responseLimit: MAX_HTTP_INPUT_BYTES,
373
+ });
374
+ }
375
+
376
+ leaseHeartbeat(jobId, leaseToken) {
377
+ return this.request('POST', `jobs/${encodeURIComponent(jobId)}/heartbeat`, {
378
+ leaseToken,
379
+ body: {},
380
+ });
381
+ }
382
+
383
+ upload(jobId, leaseToken, bytes, name, contentType) {
384
+ return this.requestBytes(`jobs/${encodeURIComponent(jobId)}/upload`, bytes, {
385
+ leaseToken,
386
+ name,
387
+ contentType,
388
+ });
389
+ }
390
+
391
+ complete(jobId, leaseToken, payload) {
392
+ return this.request('POST', `jobs/${encodeURIComponent(jobId)}/complete`, {
393
+ leaseToken,
394
+ body: payload,
395
+ });
396
+ }
397
+
398
+ fail(jobId, leaseToken, error, outcome) {
399
+ return this.request('POST', `jobs/${encodeURIComponent(jobId)}/fail`, {
400
+ leaseToken,
401
+ body: {
402
+ error: error.slice(0, 10_000),
403
+ outcome,
404
+ },
405
+ });
406
+ }
407
+ }
408
+
409
+ const validateJobEnvelope = (value) => {
410
+ const job = asObject(value);
411
+ if (!job) {
412
+ throw new Error('runner claim response did not contain a job object');
413
+ }
414
+
415
+ const id = boundedId(job.id, 'job id');
416
+ const runId = boundedId(job.runId, 'job runId');
417
+ const leaseToken = boundedString(job.leaseToken, 4096, 'lease token');
418
+ if (!leaseToken) {
419
+ throw new Error('runner claim response contained an empty lease token');
420
+ }
421
+
422
+ const leaseExpiresAt = boundedInteger(
423
+ job.leaseExpiresAt,
424
+ 'leaseExpiresAt',
425
+ 0,
426
+ Number.MAX_SAFE_INTEGER,
427
+ );
428
+ const model = job.model === undefined ? undefined : boundedString(job.model, 160, 'job model');
429
+ const reasoningEffort =
430
+ job.reasoningEffort === undefined
431
+ ? undefined
432
+ : boundedString(job.reasoningEffort, 64, 'job reasoning effort');
433
+ if (model === '' || reasoningEffort === '') {
434
+ throw new JobError('claimed job model metadata is empty');
435
+ }
436
+ return {
437
+ id,
438
+ runId,
439
+ leaseToken,
440
+ leaseExpiresAt,
441
+ ...(model === undefined ? {} : { model }),
442
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
443
+ };
444
+ };
445
+
446
+ const safeRoot = async (root, name) => {
447
+ await mkdir(root, { recursive: true, mode: 0o700 });
448
+ const resolved = await realpath(root);
449
+ if (resolved !== path.resolve(root)) {
450
+ throw new JobError(`${name} resolves unexpectedly`);
451
+ }
452
+ return resolved;
453
+ };
454
+
455
+ const safeChildDirectory = async (root, childName, label) => {
456
+ boundedId(childName, label);
457
+ const child = path.join(root, childName);
458
+ const relative = path.relative(root, child);
459
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
460
+ throw new JobError(`${label} escaped its workspace`);
461
+ }
462
+
463
+ await mkdir(child, { recursive: true, mode: 0o700 });
464
+ const resolved = await realpath(child);
465
+ if (resolved !== child || !(resolved === root || resolved.startsWith(`${root}${path.sep}`))) {
466
+ throw new JobError(`${label} resolved outside its workspace`);
467
+ }
468
+
469
+ const childStat = await lstat(child);
470
+ if (!childStat.isDirectory()) {
471
+ throw new JobError(`${label} is not a directory`);
472
+ }
473
+
474
+ return child;
475
+ };
476
+
477
+ const removeJobDirectory = async (workspaceRoot, jobDirectory) => {
478
+ if (!jobDirectory) return;
479
+ const resolvedRoot = await safeRoot(workspaceRoot, 'workspace root');
480
+ const resolvedDirectory = await realpath(jobDirectory).catch(() => undefined);
481
+ if (
482
+ !resolvedDirectory ||
483
+ resolvedDirectory === resolvedRoot ||
484
+ !resolvedDirectory.startsWith(`${resolvedRoot}${path.sep}`)
485
+ ) {
486
+ return;
487
+ }
488
+ const directoryStat = await lstat(jobDirectory).catch(() => undefined);
489
+ if (!directoryStat?.isDirectory()) {
490
+ return;
491
+ }
492
+ await rm(jobDirectory, { recursive: true, force: true });
493
+ };
494
+
495
+ const createCancellation = () => {
496
+ const controller = new AbortController();
497
+ let settled = false;
498
+ let reason;
499
+ let resolvePromise;
500
+ const listeners = new Set();
501
+ const promise = new Promise((resolve) => {
502
+ resolvePromise = resolve;
503
+ });
504
+ return {
505
+ promise,
506
+ signal: controller.signal,
507
+ get cancelled() {
508
+ return settled;
509
+ },
510
+ get reason() {
511
+ return reason;
512
+ },
513
+ onCancel(listener) {
514
+ if (settled) {
515
+ return Promise.resolve(listener(reason));
516
+ }
517
+ listeners.add(listener);
518
+ return () => listeners.delete(listener);
519
+ },
520
+ cancel(why) {
521
+ if (!settled) {
522
+ settled = true;
523
+ reason = why;
524
+ resolvePromise(reason);
525
+ controller.abort();
526
+ for (const listener of listeners) {
527
+ Promise.resolve(listener(reason)).catch(() => undefined);
528
+ }
529
+ listeners.clear();
530
+ }
531
+ },
532
+ };
533
+ };
534
+
535
+ const resultText = (result) => {
536
+ if (!Array.isArray(result?.output)) return '';
537
+ return result.output
538
+ .filter((part) => asObject(part)?.type === 'text' && typeof part.text === 'string')
539
+ .map((part) => part.text)
540
+ .join('');
541
+ };
542
+
543
+ const decodeOutputImage = (part) => {
544
+ const value = asObject(part);
545
+ if (value?.type !== 'image' || value.mimeType !== 'image/png' || typeof value.data !== 'string') {
546
+ throw new JobError('model returned an unsupported image output');
547
+ }
548
+ if (!BASE64.test(value.data)) {
549
+ throw new JobError('model returned non-canonical image base64 data');
550
+ }
551
+ const bytes = Buffer.from(value.data, 'base64');
552
+ if (bytes.length === 0 || bytes.length > MAX_OUTPUT_IMAGE_BYTES) {
553
+ throw new JobError('generated image is outside its size limit');
554
+ }
555
+ if (bytes.toString('base64') !== value.data) {
556
+ throw new JobError('model returned non-canonical image base64 data');
557
+ }
558
+ if (
559
+ bytes.length < PNG_SIGNATURE.length ||
560
+ !bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
561
+ ) {
562
+ throw new JobError('generated image output is not a PNG');
563
+ }
564
+ return bytes;
565
+ };
566
+
567
+ const parseStructuredOutput = (text, outputSchema) => {
568
+ if (!outputSchema) {
569
+ return undefined;
570
+ }
571
+
572
+ try {
573
+ return JSON.parse(text);
574
+ } catch {
575
+ throw new JobError('model did not return valid structured output');
576
+ }
577
+ };
578
+
579
+ const safeErrorOutcome = (error) =>
580
+ error instanceof JobError
581
+ ? error.outcome
582
+ : error instanceof UncertainRequestError
583
+ ? 'unknown'
584
+ : 'failed';
585
+
586
+ const cancellationOutcome = (reason) =>
587
+ reason === 'lease lost' ||
588
+ reason === 'lease expired' ||
589
+ reason === 'SIGTERM' ||
590
+ reason === 'SIGINT' ||
591
+ reason === 'shutdown'
592
+ ? 'unknown'
593
+ : 'cancelled';
594
+
595
+ const processJob = async (runner, job, config, state, source, analytics) => {
596
+ const cancellation = createCancellation();
597
+ state.cancellation = cancellation;
598
+ void analytics.emit('job', source, 'claimed');
599
+ let jobDirectory;
600
+ let leaseLost = false;
601
+ let leaseExpiresAt = job.leaseExpiresAt;
602
+ let leaseHeartbeatInFlight = false;
603
+ const leaseTimer = setInterval(() => {
604
+ if (leaseHeartbeatInFlight) {
605
+ return;
606
+ }
607
+ if (Date.now() >= leaseExpiresAt) {
608
+ leaseLost = true;
609
+ cancellation.cancel('lease expired');
610
+ return;
611
+ }
612
+ leaseHeartbeatInFlight = true;
613
+ runner
614
+ .leaseHeartbeat(job.id, job.leaseToken)
615
+ .then((response) => {
616
+ if (Number.isInteger(response?.leaseExpiresAt)) {
617
+ leaseExpiresAt = response.leaseExpiresAt;
618
+ }
619
+ if (leaseExpiresAt <= Date.now()) {
620
+ leaseLost = true;
621
+ cancellation.cancel('lease expired');
622
+ }
623
+ if (response?.cancelRequested === true) {
624
+ cancellation.cancel('cancel requested');
625
+ }
626
+ })
627
+ .catch((error) => {
628
+ if (error instanceof RunnerHttpError && error.status === 409) {
629
+ leaseLost = true;
630
+ cancellation.cancel('lease lost');
631
+ } else {
632
+ console.error(`[runner] lease heartbeat failed: ${redact(error, [job.leaseToken])}`);
633
+ if (Date.now() >= leaseExpiresAt) {
634
+ leaseLost = true;
635
+ cancellation.cancel('lease expired');
636
+ }
637
+ }
638
+ })
639
+ .finally(() => {
640
+ leaseHeartbeatInFlight = false;
641
+ });
642
+ }, LEASE_HEARTBEAT_INTERVAL_MS);
643
+
644
+ let turnTimer;
645
+ try {
646
+ if (job.leaseExpiresAt <= Date.now()) {
647
+ throw new JobError('claimed job lease has already expired', 'unknown');
648
+ }
649
+ const inputEnvelope = await runner.input(job.id, job.leaseToken);
650
+ void analytics.emit('job', source, 'input-loaded');
651
+ const models = state.health.models ?? [];
652
+ const normalizedInput = normalizeGenerationRequest(inputEnvelope, models);
653
+ void analytics.emit('job', source, 'input-validated');
654
+
655
+ const jobDirectoryName = `runner-${job.id}`;
656
+ jobDirectory = await safeChildDirectory(config.workspace, jobDirectoryName, 'job directory');
657
+ const mainJobDirectory = path.join(config.mainWorkspace, jobDirectoryName);
658
+ const mainRoot = path.resolve(config.mainWorkspace);
659
+ const mainResolved = path.resolve(mainJobDirectory);
660
+ const mainRelative = path.relative(mainRoot, mainResolved);
661
+ if (!mainRelative || mainRelative.startsWith('..') || path.isAbsolute(mainRelative)) {
662
+ throw new JobError('main job directory escaped workspace');
663
+ }
664
+ void analytics.emit('job', source, 'workspace-ready');
665
+ if (cancellation.cancelled) {
666
+ throw new JobError(
667
+ `AI job cancelled: ${cancellation.reason}`,
668
+ cancellationOutcome(cancellation.reason),
669
+ );
670
+ }
671
+
672
+ turnTimer = setTimeout(() => cancellation.cancel('turn timeout'), TURN_TIMEOUT_MS);
673
+ void analytics.emit('job', source, 'turn-started');
674
+ const result = await state.executor.execute(normalizedInput, {
675
+ signal: cancellation.signal,
676
+ directory: mainJobDirectory,
677
+ });
678
+ void analytics.emit('job', source, 'turn-completed');
679
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- leaseLost is set by the lease timer.
680
+ if (leaseLost) {
681
+ throw new JobError('job lease was lost while the model turn was running', 'unknown');
682
+ }
683
+
684
+ const wantsText = normalizedInput.output.includes('text');
685
+ const wantsImage = normalizedInput.output.includes('image');
686
+ const text = wantsText ? resultText(result) : '';
687
+ if (Buffer.byteLength(text) > MAX_OUTPUT_TEXT_BYTES) {
688
+ throw new JobError('model output exceeded its text size limit');
689
+ }
690
+ if (wantsText && normalizedInput.outputSchema !== undefined) {
691
+ parseStructuredOutput(text, normalizedInput.outputSchema);
692
+ }
693
+
694
+ const imageParts = Array.isArray(result.output)
695
+ ? result.output.filter((part) => asObject(part)?.type === 'image')
696
+ : [];
697
+ if (!wantsImage && imageParts.length > 0) {
698
+ throw new JobError('model returned an unrequested image output');
699
+ }
700
+ if (wantsImage && imageParts.length === 0) {
701
+ throw new JobError('model completed without an image output');
702
+ }
703
+ if (imageParts.length > MAX_OUTPUT_IMAGES) {
704
+ throw new JobError('model returned too many image outputs');
705
+ }
706
+ const imageBytes = [];
707
+ let totalImageBytes = 0;
708
+ for (const part of imageParts) {
709
+ const bytes = decodeOutputImage(part);
710
+ totalImageBytes += bytes.length;
711
+ if (totalImageBytes > MAX_OUTPUT_IMAGE_TOTAL_BYTES) {
712
+ throw new JobError('generated images exceed their aggregate size limit');
713
+ }
714
+ imageBytes.push(bytes);
715
+ }
716
+ void analytics.emit('job', source, 'output-validated');
717
+
718
+ const output = [];
719
+ if (wantsText) {
720
+ output.push({ type: 'text', text });
721
+ }
722
+ if (wantsImage) {
723
+ for (const [index] of imageBytes.entries()) {
724
+ output.push({ type: 'image', mimeType: 'image/png', name: `image-${index}.png` });
725
+ }
726
+ }
727
+ const responseBytes = Buffer.from(
728
+ JSON.stringify({
729
+ output,
730
+ model: normalizedInput.model,
731
+ generatedAt: Number.isInteger(result.generatedAt) ? result.generatedAt : Date.now(),
732
+ ...(result.usage === undefined ? {} : { usage: result.usage }),
733
+ }),
734
+ );
735
+ const uploadedResponse = await runner.upload(
736
+ job.id,
737
+ job.leaseToken,
738
+ responseBytes,
739
+ 'response.json',
740
+ 'application/json',
741
+ );
742
+ const responseKey = boundedString(uploadedResponse?.key, 2048, 'uploaded response key');
743
+ void analytics.emit('job', source, 'response-uploaded');
744
+ const imageKeys = [];
745
+ for (const [index, bytes] of imageBytes.entries()) {
746
+ const uploaded = await runner.upload(
747
+ job.id,
748
+ job.leaseToken,
749
+ bytes,
750
+ `image-${index}.png`,
751
+ 'image/png',
752
+ );
753
+ imageKeys.push(boundedString(uploaded?.key, 1024, 'uploaded image key'));
754
+ }
755
+ void analytics.emit('job', source, 'images-uploaded');
756
+ await runner.complete(job.id, job.leaseToken, {
757
+ responseKey,
758
+ imageKeys,
759
+ });
760
+ void analytics.emit('job', source, 'completed');
761
+ } catch (error) {
762
+ const outcome = cancellation.cancelled
763
+ ? cancellationOutcome(cancellation.reason)
764
+ : safeErrorOutcome(error);
765
+ void analytics.emit('job', source, 'failed', outcome);
766
+ const diagnostic = redact(error, [job.leaseToken]);
767
+ try {
768
+ await runner.fail(job.id, job.leaseToken, diagnostic, outcome);
769
+ void analytics.emit('job', source, 'failure-recorded', outcome);
770
+ } catch (failError) {
771
+ if (!(failError instanceof RunnerHttpError && failError.status === 409)) {
772
+ console.error(
773
+ `[runner] failed to report job ${job.id}: ${redact(failError, [job.leaseToken])}`,
774
+ );
775
+ }
776
+ void analytics.emit('job', source, 'failure-report-failed', outcome);
777
+ }
778
+ throw error;
779
+ } finally {
780
+ clearInterval(leaseTimer);
781
+ clearTimeout(turnTimer);
782
+ state.cancellation = undefined;
783
+ await removeJobDirectory(config.workspace, jobDirectory).catch((error) => {
784
+ console.error(`[runner] failed to remove job workspace: ${redact(error)}`);
785
+ });
786
+ void analytics.emit('job', source, 'cleanup-completed');
787
+ }
788
+ };
789
+
790
+ const readTokenFile = async (fileName, label) => {
791
+ const token = (await readFile(fileName, 'utf8')).trim();
792
+ if (!token) {
793
+ throw new Error(`${label} token file is empty`);
794
+ }
795
+ return token;
796
+ };
797
+
798
+ const readOptionalToken = async (fileName) => {
799
+ try {
800
+ const token = (await readFile(fileName, 'utf8')).trim();
801
+ return token || undefined;
802
+ } catch (error) {
803
+ if (error?.code === 'ENOENT') return undefined;
804
+ console.error(`[runner] optional token could not be read: ${redact(error)}`);
805
+ return undefined;
806
+ }
807
+ };
808
+
809
+ const readConfig = async (env = process.env) => {
810
+ const token = await readTokenFile(env.AG_AI_RUNNER_TOKEN_FILE ?? DEFAULT_TOKEN_FILE, 'runner');
811
+ const analyticaToken = await readTokenFile(
812
+ env.AG_AI_ANALYTICA_TOKEN_FILE ?? DEFAULT_ANALYTICA_TOKEN_FILE,
813
+ 'Analytica',
814
+ );
815
+
816
+ const config = {
817
+ queueUrl: env.AG_AI_QUEUE_URL ?? DEFAULT_QUEUE_URL,
818
+ opencodeUrl: env.AG_AI_OPENCODE_URL ?? DEFAULT_OPENCODE_URL,
819
+ opencodeUsername: env.AG_AI_OPENCODE_USERNAME,
820
+ opencodePasswordFile: env.AG_AI_OPENCODE_PASSWORD_FILE,
821
+ opencodeProvider: env.AG_AI_OPENCODE_PROVIDER,
822
+ imageProvider: env.AG_AI_IMAGE_PROVIDER ?? DEFAULT_IMAGE_PROVIDER,
823
+ imageApiKeyFile: env.AG_AI_IMAGE_API_KEY_FILE,
824
+ runnerId: env.AG_AI_RUNNER_ID ?? `ag-ai-${os.hostname()}-${randomUUID()}`,
825
+ workspace: path.resolve(env.AG_AI_WORKSPACE ?? DEFAULT_WORKSPACE),
826
+ mainWorkspace: path.resolve(env.AG_AI_MAIN_WORKSPACE ?? DEFAULT_MAIN_WORKSPACE),
827
+ generatedImages: path.resolve(env.AG_AI_GENERATED_IMAGES ?? DEFAULT_GENERATED_IMAGES),
828
+ mainGeneratedImages: path.resolve(
829
+ env.AG_AI_MAIN_GENERATED_IMAGES ?? DEFAULT_MAIN_GENERATED_IMAGES,
830
+ ),
831
+ generationHost: env.AG_AI_GENERATION_HOST,
832
+ generationPeerPrefix: env.AG_AI_GENERATION_PEER_PREFIX,
833
+ generationPort: env.AG_AI_GENERATION_PORT ?? DEFAULT_GENERATION_PORT,
834
+ generationStore: path.resolve(env.AG_AI_GENERATION_STORE ?? DEFAULT_GENERATION_STORE),
835
+ generationTokenFile: env.AG_AI_GENERATION_TOKEN_FILE ?? DEFAULT_GENERATION_TOKEN_FILE,
836
+ analyticaToken,
837
+ token,
838
+ };
839
+ normalizeBaseUrl(config.queueUrl);
840
+ return config;
841
+ };
842
+
843
+ const main = async () => {
844
+ // emptyDir survives a sidecar restart. Never inherit another process's ready signal.
845
+ await rm('/tmp/runner-health.json', { force: true });
846
+ const config = await readConfig();
847
+ const analytics = new AnalyticaEvents({ token: config.analyticaToken, source: config.runnerId });
848
+ void analytics.emit('pod', 'started');
849
+ await safeRoot(config.workspace, 'workspace root');
850
+ await safeRoot(config.generatedImages, 'generated-image root');
851
+
852
+ const imageApiKey = await readOptionalToken(config.imageApiKeyFile);
853
+ let imageAdapter;
854
+ if (imageApiKey) {
855
+ try {
856
+ imageAdapter = createImageAdapter({ provider: config.imageProvider, apiKey: imageApiKey });
857
+ } catch (error) {
858
+ console.error(`[runner] image adapter disabled: ${redact(error)}`);
859
+ }
860
+ }
861
+ const executor = await createOpenCodeExecutor({
862
+ baseUrl: config.opencodeUrl,
863
+ username: config.opencodeUsername,
864
+ passwordFile: config.opencodePasswordFile,
865
+ providerID: config.opencodeProvider,
866
+ imageAdapter,
867
+ });
868
+
869
+ const runner = new RunnerClient({
870
+ baseUrl: config.queueUrl,
871
+ token: config.token,
872
+ runnerId: config.runnerId,
873
+ analytics,
874
+ });
875
+ const state = {
876
+ job: undefined,
877
+ cancellation: undefined,
878
+ stopping: false,
879
+ executor,
880
+ health: { serverReady: false, modelReady: false, models: [] },
881
+ healthPromise: undefined,
882
+ };
883
+
884
+ const generationToken = await readOptionalToken(config.generationTokenFile);
885
+ const generationStore = generationToken ? new GenerationStore(config.generationStore) : undefined;
886
+ // The LAN listener binds only where the host supplies a private bind address.
887
+ const generationServer =
888
+ generationToken && config.generationHost
889
+ ? await startGenerationServer({
890
+ host: config.generationHost,
891
+ port: config.generationPort,
892
+ token: String(generationToken),
893
+ store: generationStore,
894
+ allowedPeerPrefix: config.generationPeerPrefix,
895
+ getModels: () => state.health.models,
896
+ isReady: () => state.health.serverReady && state.health.modelReady,
897
+ analytics,
898
+ })
899
+ : undefined;
900
+ if (generationServer) {
901
+ const address = generationServer.address();
902
+ console.log(`[runner] LAN generation listener ready on ${address.address}:${address.port}`);
903
+ } else {
904
+ console.log(
905
+ '[runner] LAN generation listener disabled (generation token or bind host is absent)',
906
+ );
907
+ }
908
+
909
+ const refreshHealth = async () => {
910
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- the promise is set across async calls.
911
+ if (state.healthPromise) {
912
+ return state.healthPromise;
913
+ }
914
+ state.healthPromise = (async () => {
915
+ try {
916
+ await executor.health();
917
+ const models = await executor.listModels();
918
+ state.health = { serverReady: true, modelReady: models.length > 0, models };
919
+ } catch (error) {
920
+ state.health = { serverReady: false, modelReady: false, models: [] };
921
+ console.error(`[runner] OpenCode health check failed: ${redact(error)}`);
922
+ }
923
+ return state.health;
924
+ })().finally(() => {
925
+ state.healthPromise = undefined;
926
+ });
927
+ return state.healthPromise;
928
+ };
929
+
930
+ let heartbeatPromise;
931
+ const sendHeartbeat = async () => {
932
+ if (heartbeatPromise) {
933
+ return heartbeatPromise;
934
+ }
935
+ heartbeatPromise = runner
936
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- state.job is assigned in the claim loop.
937
+ .heartbeat(state.job ? 1 : 0, state.health.serverReady, state.health.modelReady)
938
+ .then(() => true)
939
+ .catch((error) => {
940
+ console.error(`[runner] runner heartbeat failed: ${redact(error, [config.token])}`);
941
+ return false;
942
+ })
943
+ .then(async (workerReachable) => {
944
+ // Probe data is private to this sidecar and contains no credentials.
945
+ const backendReady = state.health.serverReady && state.health.modelReady;
946
+ const lanReady = generationServer !== undefined && backendReady;
947
+ await writeFile(
948
+ '/tmp/runner-health.tmp',
949
+ JSON.stringify({
950
+ checkedAt: Date.now(),
951
+ ready: !state.stopping && (lanReady || (workerReachable && backendReady)),
952
+ }),
953
+ { mode: 0o600 },
954
+ );
955
+ await rename('/tmp/runner-health.tmp', '/tmp/runner-health.json');
956
+ })
957
+ .catch(() => console.error('[runner] could not update local health probe'))
958
+ .finally(() => {
959
+ heartbeatPromise = undefined;
960
+ });
961
+ return heartbeatPromise;
962
+ };
963
+
964
+ let shutdownPromise;
965
+ const stop = async (reason) => {
966
+ if (shutdownPromise) {
967
+ return shutdownPromise;
968
+ }
969
+ shutdownPromise = (async () => {
970
+ state.stopping = true;
971
+ void analytics.emit('pod', 'stopping');
972
+ state.cancellation?.cancel(reason);
973
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- state.job is assigned in the claim loop.
974
+ await Promise.race([state.job ?? Promise.resolve(), delay(SHUTDOWN_TIMEOUT_MS)]);
975
+ await generationServer?.close();
976
+ await generationStore?.close();
977
+ await analytics.flush();
978
+ })();
979
+ return shutdownPromise;
980
+ };
981
+ process.once('SIGTERM', () => void stop('SIGTERM'));
982
+ process.once('SIGINT', () => void stop('SIGINT'));
983
+
984
+ const heartbeatTimer = setInterval(() => void sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
985
+ const healthTimer = setInterval(() => void refreshHealth(), HEALTH_INTERVAL_MS);
986
+ const claimLocal = async () => {
987
+ if (!generationStore) return undefined;
988
+ try {
989
+ const record = await generationStore.claimNext();
990
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- claimNext returns a record or undefined.
991
+ return record
992
+ ? {
993
+ source: 'local',
994
+ runner: createLocalRunner({ store: generationStore, record }),
995
+ job: undefined,
996
+ }
997
+ : undefined;
998
+ } catch (error) {
999
+ console.error(`[runner] local generation claim failed: ${redact(error)}`);
1000
+ return undefined;
1001
+ }
1002
+ };
1003
+ const claimCloud = async () => {
1004
+ let claimResponse;
1005
+ try {
1006
+ claimResponse = await runner.claim();
1007
+ } catch (error) {
1008
+ console.error(`[runner] claim failed: ${redact(error, [config.token])}`);
1009
+ if (error instanceof UncertainRequestError) return { uncertain: true };
1010
+ return undefined;
1011
+ }
1012
+ if (!claimResponse?.job) return undefined;
1013
+ try {
1014
+ return { source: 'cloud', runner, job: validateJobEnvelope(claimResponse.job) };
1015
+ } catch (error) {
1016
+ console.error(`[runner] invalid claimed job: ${redact(error, [config.token])}`);
1017
+ return { uncertain: true };
1018
+ }
1019
+ };
1020
+ let preferLocal = false;
1021
+ try {
1022
+ await refreshHealth();
1023
+ await sendHeartbeat();
1024
+ while (!state.stopping) {
1025
+ if (!state.health.serverReady || !state.health.modelReady) {
1026
+ await refreshHealth();
1027
+ await delay(CLAIM_INTERVAL_MS);
1028
+ continue;
1029
+ }
1030
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- state.job is assigned in the claim loop.
1031
+ if (state.job) {
1032
+ await delay(250);
1033
+ continue;
1034
+ }
1035
+
1036
+ const first = preferLocal ? await claimLocal() : await claimCloud();
1037
+ if (first?.uncertain) {
1038
+ await delay(CLAIM_INTERVAL_MS);
1039
+ continue;
1040
+ }
1041
+ const selection = first ?? (preferLocal ? await claimCloud() : await claimLocal());
1042
+ if (selection?.uncertain) {
1043
+ await delay(CLAIM_INTERVAL_MS);
1044
+ continue;
1045
+ }
1046
+ if (!selection) {
1047
+ await delay(CLAIM_INTERVAL_MS);
1048
+ continue;
1049
+ }
1050
+ const activeRunner = selection.runner;
1051
+ const job = selection.job ?? activeRunner.job;
1052
+ preferLocal = selection.source === 'cloud';
1053
+ const jobPromise = processJob(activeRunner, job, config, state, selection.source, analytics)
1054
+ .catch((error) => {
1055
+ console.error(`[runner] job ${job.id} ended: ${redact(error, [job.leaseToken])}`);
1056
+ })
1057
+ .finally(() => {
1058
+ state.job = undefined;
1059
+ });
1060
+ state.job = jobPromise;
1061
+ await jobPromise;
1062
+ }
1063
+ } finally {
1064
+ clearInterval(heartbeatTimer);
1065
+ clearInterval(healthTimer);
1066
+ await stop('shutdown');
1067
+ }
1068
+ };
1069
+
1070
+ export {
1071
+ JobError,
1072
+ RunnerClient,
1073
+ RunnerHttpError,
1074
+ UncertainRequestError,
1075
+ createCancellation,
1076
+ emitRunnerHttpEvent,
1077
+ normalizeBaseUrl,
1078
+ processJob,
1079
+ readConfig,
1080
+ safeErrorOutcome,
1081
+ validateJobEnvelope,
1082
+ };
1083
+
1084
+ if (
1085
+ process.argv[1] &&
1086
+ (await realpath(process.argv[1]).catch(() => '')) ===
1087
+ (await realpath(fileURLToPath(import.meta.url)))
1088
+ ) {
1089
+ main().catch((error) => {
1090
+ console.error(`[runner] fatal: ${redact(error)}`);
1091
+ process.exitCode = 1;
1092
+ });
1093
+ }