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,568 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import { mkdir, open, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ const STORE_VERSION = 1;
7
+ const MAX_ERROR_LENGTH = 10_000;
8
+ const MAX_QUEUED_JOBS = 8;
9
+ const MAX_QUEUED_REQUEST_BYTES = 128 * 1024 * 1024;
10
+ const MAX_RETAINED_TERMINAL_JOBS = 256;
11
+ const TERMINAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
12
+ const TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
13
+ const MAX_TOMBSTONES = 1024;
14
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
15
+ const RECORD_FILE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}\.json$/;
16
+ const TOMBSTONE_FILE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}\.tombstone\.json$/;
17
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'unknown']);
18
+ const ALL_STATUSES = new Set([
19
+ 'queued',
20
+ 'running',
21
+ 'cancelling',
22
+ 'completed',
23
+ 'failed',
24
+ 'cancelled',
25
+ 'unknown',
26
+ ]);
27
+
28
+ class GenerationStoreError extends Error {
29
+ constructor(message, code = 'store_error') {
30
+ super(message);
31
+ this.name = 'GenerationStoreError';
32
+ this.code = code;
33
+ }
34
+ }
35
+
36
+ const clone = (value) => JSON.parse(JSON.stringify(value));
37
+
38
+ const safeId = (value) => {
39
+ if (typeof value !== 'string' || !SAFE_ID.test(value)) {
40
+ throw new GenerationStoreError('job id is invalid', 'invalid_job');
41
+ }
42
+ return value;
43
+ };
44
+
45
+ const safeRoot = async (root) => {
46
+ const resolvedRoot = path.resolve(root);
47
+ await mkdir(resolvedRoot, { recursive: true, mode: 0o700 });
48
+ const canonical = await realpath(resolvedRoot);
49
+ if (canonical !== resolvedRoot) {
50
+ throw new GenerationStoreError('generation store path resolves unexpectedly', 'unsafe_store');
51
+ }
52
+ const rootStat = await stat(canonical);
53
+ if (!rootStat.isDirectory()) {
54
+ throw new GenerationStoreError('generation store path is not a directory', 'unsafe_store');
55
+ }
56
+ return canonical;
57
+ };
58
+
59
+ const safePath = (root, id, suffix) => {
60
+ safeId(id);
61
+ const name = `${id}${suffix}`;
62
+ const target = path.join(root, name);
63
+ const relative = path.relative(root, target);
64
+ if (relative !== name || path.isAbsolute(relative)) {
65
+ throw new GenerationStoreError('job record escaped its store', 'unsafe_store');
66
+ }
67
+ return target;
68
+ };
69
+
70
+ const checkHashes = (record, fileName) => {
71
+ if (
72
+ typeof record.idempotencyKeyHash !== 'string' ||
73
+ !/^[a-f0-9]{64}$/.test(record.idempotencyKeyHash)
74
+ ) {
75
+ throw new GenerationStoreError(`invalid idempotency hash ${fileName}`, 'corrupt_store');
76
+ }
77
+ if (typeof record.requestHash !== 'string' || !/^[a-f0-9]{64}$/.test(record.requestHash)) {
78
+ throw new GenerationStoreError(`invalid request hash ${fileName}`, 'corrupt_store');
79
+ }
80
+ };
81
+
82
+ const validateMetadata = (value, fileName, { body = true } = {}) => {
83
+ if (!value || typeof value !== 'object' || value.version !== STORE_VERSION) {
84
+ throw new GenerationStoreError(`invalid generation record ${fileName}`, 'corrupt_store');
85
+ }
86
+ const record = value;
87
+ safeId(record.id);
88
+ checkHashes(record, fileName);
89
+ if (!ALL_STATUSES.has(record.status)) {
90
+ throw new GenerationStoreError(`invalid generation status ${fileName}`, 'corrupt_store');
91
+ }
92
+ if (!Number.isInteger(record.createdAt) || !Number.isInteger(record.updatedAt)) {
93
+ throw new GenerationStoreError(`invalid generation timestamps ${fileName}`, 'corrupt_store');
94
+ }
95
+ if (typeof record.model !== 'string' || typeof record.reasoningEffort !== 'string') {
96
+ throw new GenerationStoreError(
97
+ `generation model metadata is missing ${fileName}`,
98
+ 'corrupt_store',
99
+ );
100
+ }
101
+ if (
102
+ record.requestBytes !== undefined &&
103
+ (!Number.isInteger(record.requestBytes) || record.requestBytes < 0)
104
+ ) {
105
+ throw new GenerationStoreError(
106
+ `generation request size is invalid ${fileName}`,
107
+ 'corrupt_store',
108
+ );
109
+ }
110
+ if (record.error !== undefined && (!record.error || typeof record.error !== 'object')) {
111
+ throw new GenerationStoreError(`generation error is invalid ${fileName}`, 'corrupt_store');
112
+ }
113
+ if (body && (!record.request || typeof record.request !== 'object')) {
114
+ throw new GenerationStoreError(`generation request missing ${fileName}`, 'corrupt_store');
115
+ }
116
+ if (body && record.result !== undefined && typeof record.result !== 'object') {
117
+ throw new GenerationStoreError(`generation result is invalid ${fileName}`, 'corrupt_store');
118
+ }
119
+ return record;
120
+ };
121
+
122
+ const toMetadata = (record, bodyPresent = true) => ({
123
+ version: STORE_VERSION,
124
+ id: record.id,
125
+ idempotencyKeyHash: record.idempotencyKeyHash,
126
+ requestHash: record.requestHash,
127
+ model: record.model,
128
+ reasoningEffort: record.reasoningEffort,
129
+ requestBytes: record.requestBytes ?? 0,
130
+ status: record.status,
131
+ createdAt: record.createdAt,
132
+ updatedAt: record.updatedAt,
133
+ ...(record.startedAt === undefined ? {} : { startedAt: record.startedAt }),
134
+ ...(record.completedAt === undefined ? {} : { completedAt: record.completedAt }),
135
+ cancelRequested: record.cancelRequested === true,
136
+ ...(record.error === undefined ? {} : { error: clone(record.error) }),
137
+ bodyPresent,
138
+ });
139
+
140
+ const publicFromRecord = (record) => {
141
+ const value = { id: record.id, status: record.status };
142
+ if (record.result !== undefined) value.result = clone(record.result);
143
+ if (record.error !== undefined) value.error = clone(record.error);
144
+ return value;
145
+ };
146
+
147
+ const terminalSort = (a, b) => a.updatedAt - b.updatedAt || a.createdAt - b.createdAt;
148
+
149
+ class GenerationStore {
150
+ constructor(root) {
151
+ this.rootInput = root;
152
+ this.root = undefined;
153
+ // Keep only bounded metadata in memory. Requests and results stay on disk.
154
+ this.records = new Map();
155
+ this.idempotency = new Map();
156
+ this.mutation = Promise.resolve();
157
+ this.initialized = false;
158
+ }
159
+
160
+ async init() {
161
+ if (this.initialized) return;
162
+ this.root = await safeRoot(this.rootInput);
163
+ const entries = await readdir(this.root, { withFileTypes: true });
164
+ const candidates = new Map();
165
+ for (const entry of entries) {
166
+ if (!entry.isFile() || (!RECORD_FILE.test(entry.name) && !TOMBSTONE_FILE.test(entry.name))) {
167
+ continue;
168
+ }
169
+ const isTombstone = TOMBSTONE_FILE.test(entry.name);
170
+ const id = isTombstone
171
+ ? entry.name.slice(0, -'.tombstone.json'.length)
172
+ : entry.name.slice(0, -'.json'.length);
173
+ safeId(id);
174
+ const recordPath = path.join(this.root, entry.name);
175
+ // eslint-disable-next-line no-await-in-loop -- Read records in deterministic order.
176
+ const handle = await open(recordPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
177
+ let parsed;
178
+ try {
179
+ // eslint-disable-next-line no-await-in-loop -- Preserve deterministic record validation.
180
+ parsed = JSON.parse(await handle.readFile('utf8'));
181
+ } catch {
182
+ throw new GenerationStoreError(
183
+ `could not read generation record ${entry.name}`,
184
+ 'corrupt_store',
185
+ );
186
+ } finally {
187
+ // eslint-disable-next-line no-await-in-loop -- Close each record before advancing.
188
+ await handle.close();
189
+ }
190
+ const record = validateMetadata(parsed, entry.name, { body: !isTombstone });
191
+ if (record.id !== id) {
192
+ throw new GenerationStoreError('generation record identity mismatch', 'corrupt_store');
193
+ }
194
+ const candidate = candidates.get(id) ?? {};
195
+ if (isTombstone ? candidate.tombstone : candidate.body) {
196
+ throw new GenerationStoreError('duplicate generation record', 'corrupt_store');
197
+ }
198
+ candidate[isTombstone ? 'tombstone' : 'body'] = { record, entry: entry.name };
199
+ candidates.set(id, candidate);
200
+ }
201
+
202
+ const staleBodies = [];
203
+ for (const [id, candidate] of candidates) {
204
+ let selected;
205
+ let bodyPresent;
206
+ if (candidate.tombstone && candidate.body) {
207
+ const tombstone = candidate.tombstone.record;
208
+ const body = candidate.body.record;
209
+ if (
210
+ tombstone.idempotencyKeyHash !== body.idempotencyKeyHash ||
211
+ tombstone.requestHash !== body.requestHash ||
212
+ tombstone.model !== body.model ||
213
+ tombstone.reasoningEffort !== body.reasoningEffort ||
214
+ tombstone.status !== body.status ||
215
+ tombstone.updatedAt !== body.updatedAt
216
+ ) {
217
+ throw new GenerationStoreError('generation compaction records disagree', 'corrupt_store');
218
+ }
219
+ // A tombstone is written before its body is removed. Treat it as the
220
+ // committed state and finish the cleanup left by that crash window.
221
+ selected = tombstone;
222
+ bodyPresent = false;
223
+ staleBodies.push(id);
224
+ } else if (candidate.tombstone) {
225
+ selected = candidate.tombstone.record;
226
+ bodyPresent = false;
227
+ } else {
228
+ selected = candidate.body.record;
229
+ bodyPresent = true;
230
+ }
231
+ if (this.idempotency.has(selected.idempotencyKeyHash)) {
232
+ throw new GenerationStoreError('duplicate generation idempotency key', 'corrupt_store');
233
+ }
234
+ this.records.set(id, toMetadata(selected, bodyPresent));
235
+ this.idempotency.set(selected.idempotencyKeyHash, id);
236
+ }
237
+ this.initialized = true;
238
+ await this._mutate(async () => {
239
+ for (const id of staleBodies) {
240
+ // eslint-disable-next-line no-await-in-loop -- Finish tombstone cleanup sequentially.
241
+ await rm(safePath(this.root, id, '.json'), { force: true });
242
+ }
243
+ for (const metadata of this.records.values()) {
244
+ if (
245
+ metadata.bodyPresent &&
246
+ (metadata.status === 'running' || metadata.status === 'cancelling')
247
+ ) {
248
+ // eslint-disable-next-line no-await-in-loop -- Recover running records one at a time.
249
+ const record = await this._readFull(metadata.id);
250
+ record.status = 'unknown';
251
+ record.cancelRequested = false;
252
+ record.error = {
253
+ code: 'unknown_after_restart',
254
+ message: 'Execution outcome is unknown after bridge restart',
255
+ };
256
+ record.updatedAt = Date.now();
257
+ // eslint-disable-next-line no-await-in-loop -- Persist each recovery before pruning.
258
+ await this._persist(record);
259
+ this._replaceMetadata(record, true);
260
+ }
261
+ }
262
+ await this._prune();
263
+ });
264
+ }
265
+
266
+ async _mutate(operation) {
267
+ const next = this.mutation.then(operation, operation);
268
+ this.mutation = next.catch(() => undefined);
269
+ return next;
270
+ }
271
+
272
+ _replaceMetadata(record, bodyPresent = true) {
273
+ const metadata = toMetadata(record, bodyPresent);
274
+ this.records.set(record.id, metadata);
275
+ this.idempotency.set(record.idempotencyKeyHash, record.id);
276
+ return metadata;
277
+ }
278
+
279
+ async _readFull(id) {
280
+ const metadata = this.records.get(id);
281
+ if (!metadata) return undefined;
282
+ if (!metadata.bodyPresent) return { ...metadata };
283
+ const target = safePath(this.root, id, '.json');
284
+ const handle = await open(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
285
+ try {
286
+ return validateMetadata(JSON.parse(await handle.readFile('utf8')), `${id}.json`, {
287
+ body: true,
288
+ });
289
+ } catch (error) {
290
+ if (error instanceof GenerationStoreError) throw error;
291
+ throw new GenerationStoreError(
292
+ `could not read generation record ${id}.json`,
293
+ 'corrupt_store',
294
+ );
295
+ } finally {
296
+ await handle.close();
297
+ }
298
+ }
299
+
300
+ async _persist(record) {
301
+ if (!this.root) throw new GenerationStoreError('generation store is not initialized');
302
+ const target = safePath(this.root, record.id, '.json');
303
+ const temporary = path.join(this.root, `.${record.id}.${randomUUID()}.tmp`);
304
+ const serialized = JSON.stringify(record);
305
+ try {
306
+ await writeFile(temporary, serialized, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
307
+ await rename(temporary, target);
308
+ } finally {
309
+ await rm(temporary, { force: true }).catch(() => undefined);
310
+ }
311
+ }
312
+
313
+ async _persistTombstone(metadata) {
314
+ const target = safePath(this.root, metadata.id, '.tombstone.json');
315
+ const temporary = path.join(this.root, `.${metadata.id}.${randomUUID()}.tombstone.tmp`);
316
+ const tombstone = { ...metadata, bodyPresent: false };
317
+ try {
318
+ await writeFile(temporary, JSON.stringify(tombstone), {
319
+ encoding: 'utf8',
320
+ mode: 0o600,
321
+ flag: 'wx',
322
+ });
323
+ await rename(temporary, target);
324
+ } finally {
325
+ await rm(temporary, { force: true }).catch(() => undefined);
326
+ }
327
+ }
328
+
329
+ async _compact(metadata) {
330
+ if (!metadata.bodyPresent || !TERMINAL_STATUSES.has(metadata.status)) return;
331
+ await this._persistTombstone(metadata);
332
+ await rm(safePath(this.root, metadata.id, '.json'), { force: true });
333
+ this.records.set(metadata.id, { ...metadata, bodyPresent: false });
334
+ }
335
+
336
+ async _remove(metadata) {
337
+ await rm(
338
+ metadata.bodyPresent
339
+ ? safePath(this.root, metadata.id, '.json')
340
+ : safePath(this.root, metadata.id, '.tombstone.json'),
341
+ { force: true },
342
+ );
343
+ this.records.delete(metadata.id);
344
+ if (this.idempotency.get(metadata.idempotencyKeyHash) === metadata.id) {
345
+ this.idempotency.delete(metadata.idempotencyKeyHash);
346
+ }
347
+ }
348
+
349
+ async _prune() {
350
+ const now = Date.now();
351
+ const terminal = [...this.records.values()]
352
+ .filter((record) => TERMINAL_STATUSES.has(record.status))
353
+ .sort(terminalSort);
354
+ const terminalBodies = terminal.filter((record) => record.bodyPresent);
355
+ const compact = terminalBodies.filter(
356
+ (record, index) =>
357
+ now - record.updatedAt > TERMINAL_RETENTION_MS ||
358
+ index < Math.max(0, terminalBodies.length - MAX_RETAINED_TERMINAL_JOBS),
359
+ );
360
+ for (const record of compact) {
361
+ // eslint-disable-next-line no-await-in-loop -- Tombstones must precede body removal.
362
+ await this._compact(record);
363
+ }
364
+ const tombstones = [...this.records.values()]
365
+ .filter((record) => !record.bodyPresent)
366
+ .sort(terminalSort);
367
+ const remove = tombstones.filter(
368
+ (record, index) =>
369
+ now - record.updatedAt > TOMBSTONE_RETENTION_MS ||
370
+ index < Math.max(0, tombstones.length - MAX_TOMBSTONES),
371
+ );
372
+ for (const record of remove) {
373
+ // eslint-disable-next-line no-await-in-loop -- Remove records sequentially after compaction.
374
+ await this._remove(record);
375
+ }
376
+ }
377
+
378
+ async create({ idempotencyKeyHash, requestHash, request, model, reasoningEffort }) {
379
+ await this._ready();
380
+ return this._mutate(async () => {
381
+ const existingId = this.idempotency.get(idempotencyKeyHash);
382
+ if (existingId) {
383
+ const existing = this.records.get(existingId);
384
+ if (!existing) {
385
+ throw new GenerationStoreError('idempotency index is corrupt', 'corrupt_store');
386
+ }
387
+ if (existing.requestHash !== requestHash) {
388
+ throw new GenerationStoreError(
389
+ 'idempotency key was already used for a different request',
390
+ 'idempotency_conflict',
391
+ );
392
+ }
393
+ return { record: clone(existing), created: false };
394
+ }
395
+ const requestBytes = Buffer.byteLength(JSON.stringify(request));
396
+ const queued = [...this.records.values()].filter((record) => record.status === 'queued');
397
+ const queuedBytes = queued.reduce((sum, record) => sum + (record.requestBytes ?? 0), 0);
398
+ if (
399
+ queued.length >= MAX_QUEUED_JOBS ||
400
+ queuedBytes + requestBytes > MAX_QUEUED_REQUEST_BYTES
401
+ ) {
402
+ throw new GenerationStoreError('generation queue is full', 'queue_full');
403
+ }
404
+ const id = randomUUID();
405
+ const now = Date.now();
406
+ const record = {
407
+ version: STORE_VERSION,
408
+ id,
409
+ idempotencyKeyHash,
410
+ requestHash,
411
+ request: clone(request),
412
+ model,
413
+ reasoningEffort,
414
+ requestBytes,
415
+ status: 'queued',
416
+ createdAt: now,
417
+ updatedAt: now,
418
+ cancelRequested: false,
419
+ };
420
+ this._replaceMetadata(record, true);
421
+ try {
422
+ await this._persist(record);
423
+ } catch (error) {
424
+ this.records.delete(id);
425
+ this.idempotency.delete(idempotencyKeyHash);
426
+ throw error;
427
+ }
428
+ await this._prune();
429
+ return { record: clone(this.records.get(id)), created: true };
430
+ });
431
+ }
432
+
433
+ async _ready() {
434
+ if (!this.initialized) throw new GenerationStoreError('generation store is not initialized');
435
+ }
436
+
437
+ async get(id) {
438
+ await this._ready();
439
+ safeId(id);
440
+ const record = await this._readFull(id);
441
+ return record ? clone(record) : undefined;
442
+ }
443
+
444
+ async findByIdempotencyKeyHash(idempotencyKeyHash) {
445
+ await this._ready();
446
+ const id = this.idempotency.get(idempotencyKeyHash);
447
+ const record = id ? this.records.get(id) : undefined;
448
+ return record ? clone(record) : undefined;
449
+ }
450
+
451
+ async claimNext() {
452
+ await this._ready();
453
+ return this._mutate(async () => {
454
+ const queued = [...this.records.values()]
455
+ .filter((record) => record.status === 'queued' && record.bodyPresent)
456
+ .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))[0];
457
+ if (!queued) return undefined;
458
+ const record = await this._readFull(queued.id);
459
+ record.status = 'running';
460
+ record.startedAt = Date.now();
461
+ record.updatedAt = record.startedAt;
462
+ await this._persist(record);
463
+ this._replaceMetadata(record, true);
464
+ return clone(record);
465
+ });
466
+ }
467
+
468
+ async complete(id, result) {
469
+ await this._ready();
470
+ return this._mutate(async () => {
471
+ safeId(id);
472
+ const metadata = this.records.get(id);
473
+ if (!metadata) throw new GenerationStoreError('generation job was not found', 'not_found');
474
+ if (TERMINAL_STATUSES.has(metadata.status)) return clone(metadata);
475
+ const record = await this._readFull(id);
476
+ record.status = 'completed';
477
+ record.cancelRequested = false;
478
+ record.result = clone(result);
479
+ record.error = undefined;
480
+ record.completedAt = Date.now();
481
+ record.updatedAt = record.completedAt;
482
+ await this._persist(record);
483
+ this._replaceMetadata(record, true);
484
+ await this._prune();
485
+ return clone(record);
486
+ });
487
+ }
488
+
489
+ async fail(id, message, outcome = 'failed') {
490
+ await this._ready();
491
+ return this._mutate(async () => {
492
+ safeId(id);
493
+ const metadata = this.records.get(id);
494
+ if (!metadata) throw new GenerationStoreError('generation job was not found', 'not_found');
495
+ if (TERMINAL_STATUSES.has(metadata.status)) return clone(metadata);
496
+ const record = await this._readFull(id);
497
+ const status =
498
+ outcome === 'cancelled' ? 'cancelled' : outcome === 'unknown' ? 'unknown' : 'failed';
499
+ record.status = status;
500
+ record.cancelRequested = false;
501
+ record.error = {
502
+ code: status === 'cancelled' ? 'cancelled' : status,
503
+ message: String(message ?? 'Generation failed').slice(0, MAX_ERROR_LENGTH),
504
+ };
505
+ record.completedAt = Date.now();
506
+ record.updatedAt = record.completedAt;
507
+ await this._persist(record);
508
+ this._replaceMetadata(record, true);
509
+ await this._prune();
510
+ return clone(record);
511
+ });
512
+ }
513
+
514
+ async cancel(id) {
515
+ await this._ready();
516
+ return this._mutate(async () => {
517
+ safeId(id);
518
+ const metadata = this.records.get(id);
519
+ if (!metadata) return undefined;
520
+ if (metadata.status === 'queued') {
521
+ const record = await this._readFull(id);
522
+ record.status = 'cancelled';
523
+ record.cancelRequested = false;
524
+ record.error = {
525
+ code: 'cancelled',
526
+ message: 'Generation was cancelled before execution',
527
+ };
528
+ record.completedAt = Date.now();
529
+ record.updatedAt = record.completedAt;
530
+ await this._persist(record);
531
+ this._replaceMetadata(record, true);
532
+ } else if (metadata.status === 'running') {
533
+ const record = await this._readFull(id);
534
+ record.status = 'cancelling';
535
+ record.cancelRequested = true;
536
+ record.updatedAt = Date.now();
537
+ await this._persist(record);
538
+ this._replaceMetadata(record, true);
539
+ }
540
+ await this._prune();
541
+ return clone(this.records.get(id));
542
+ });
543
+ }
544
+
545
+ async isCancellationRequested(id) {
546
+ await this._ready();
547
+ const record = this.records.get(id);
548
+ return Boolean(record?.cancelRequested || record?.status === 'cancelling');
549
+ }
550
+
551
+ publicJob(record) {
552
+ return record ? publicFromRecord(record) : undefined;
553
+ }
554
+
555
+ async close() {
556
+ await this.mutation;
557
+ }
558
+ }
559
+
560
+ export {
561
+ GenerationStore,
562
+ GenerationStoreError,
563
+ MAX_QUEUED_JOBS,
564
+ MAX_QUEUED_REQUEST_BYTES,
565
+ MAX_RETAINED_TERMINAL_JOBS,
566
+ TERMINAL_RETENTION_MS,
567
+ TOMBSTONE_RETENTION_MS,
568
+ };
@@ -0,0 +1,137 @@
1
+ /* eslint-disable no-console */
2
+
3
+ const ANALYTICA_EVENT_URL = 'https://analytica.click/api/site/event';
4
+ const DEFAULT_ANALYTICA_EVENT_PREFIX = 'andreigec/ag-ai';
5
+ const DEFAULT_ANALYTICA_PAGE_LOCATION = 'cluster://ag-ai';
6
+ const ANALYTICA_REQUEST_TIMEOUT_MS = 5_000;
7
+ const ANALYTICA_MAX_CONCURRENT_REQUESTS = 4;
8
+ const ANALYTICA_MAX_QUEUED_REQUESTS = 256;
9
+
10
+ const normalizeSegment = (value) =>
11
+ String(value)
12
+ .trim()
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9-]+/g, '-')
15
+ .replace(/^-+|-+$/g, '')
16
+ .slice(0, 80) || 'unknown';
17
+
18
+ const analyticaEventName = (prefix, ...segments) =>
19
+ [prefix, ...segments.map(normalizeSegment)].join('/');
20
+
21
+ class AnalyticaEvents {
22
+ constructor({
23
+ token,
24
+ source,
25
+ eventUrl = ANALYTICA_EVENT_URL,
26
+ fetchImpl = globalThis.fetch,
27
+ timeoutMs = ANALYTICA_REQUEST_TIMEOUT_MS,
28
+ maxConcurrent = ANALYTICA_MAX_CONCURRENT_REQUESTS,
29
+ maxQueued = ANALYTICA_MAX_QUEUED_REQUESTS,
30
+ eventPrefix = process.env.AG_AI_ANALYTICA_EVENT_PREFIX ?? DEFAULT_ANALYTICA_EVENT_PREFIX,
31
+ pageLocation = process.env.AG_AI_ANALYTICA_PAGE_LOCATION ?? DEFAULT_ANALYTICA_PAGE_LOCATION,
32
+ } = {}) {
33
+ if (typeof token !== 'string' || token.trim().length === 0) {
34
+ throw new Error('Analytica token is required');
35
+ }
36
+ if (typeof source !== 'string' || source.trim().length === 0) {
37
+ throw new Error('Analytica source is required');
38
+ }
39
+ if (typeof eventPrefix !== 'string' || eventPrefix.trim().length === 0) {
40
+ throw new Error('Analytica event prefix is required');
41
+ }
42
+ if (typeof fetchImpl !== 'function')
43
+ throw new Error('Analytica fetch implementation is required');
44
+ if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1)
45
+ throw new Error('Analytica concurrency must be a positive integer');
46
+ if (!Number.isInteger(maxQueued) || maxQueued < 0)
47
+ throw new Error('Analytica queue limit must be a non-negative integer');
48
+ this.token = token.trim();
49
+ this.source = source.trim().slice(0, 160);
50
+ this.eventUrl = new URL(eventUrl).toString();
51
+ this.fetchImpl = fetchImpl;
52
+ this.timeoutMs = timeoutMs;
53
+ this.maxConcurrent = maxConcurrent;
54
+ this.maxQueued = maxQueued;
55
+ this.eventPrefix = eventPrefix.trim();
56
+ this.pageLocation = String(pageLocation).trim().slice(0, 200);
57
+ this.active = 0;
58
+ this.queue = [];
59
+ this.pending = new Set();
60
+ }
61
+
62
+ emit(...segments) {
63
+ const eventName = analyticaEventName(this.eventPrefix, ...segments);
64
+ console.log(`[analytica] ${JSON.stringify({ eventName })}`);
65
+ if (this.active >= this.maxConcurrent && this.queue.length >= this.maxQueued) {
66
+ console.error('[analytica] delivery dropped because the bounded queue is full');
67
+ return Promise.resolve(false);
68
+ }
69
+
70
+ let resolveDelivery;
71
+ const delivery = new Promise((resolve) => {
72
+ resolveDelivery = resolve;
73
+ });
74
+ this.pending.add(delivery);
75
+ void delivery.finally(() => this.pending.delete(delivery));
76
+ const queued = { eventName, resolve: resolveDelivery };
77
+ if (this.active < this.maxConcurrent) this.start(queued);
78
+ else this.queue.push(queued);
79
+ return delivery;
80
+ }
81
+
82
+ start(queued) {
83
+ this.active += 1;
84
+ void this.deliver(queued.eventName)
85
+ .then(queued.resolve)
86
+ .finally(() => {
87
+ this.active -= 1;
88
+ const next = this.queue.shift();
89
+ if (next) this.start(next);
90
+ });
91
+ }
92
+
93
+ async deliver(eventName) {
94
+ try {
95
+ const response = await this.fetchImpl(this.eventUrl, {
96
+ method: 'POST',
97
+ headers: {
98
+ authorization: '',
99
+ 'content-type': 'application/json',
100
+ },
101
+ body: JSON.stringify({
102
+ key: this.token,
103
+ data: {
104
+ eventName,
105
+ browserFingerprint: this.source,
106
+ pageLocation: this.pageLocation,
107
+ },
108
+ }),
109
+ redirect: 'error',
110
+ signal: AbortSignal.timeout(this.timeoutMs),
111
+ });
112
+ if (!response.ok) {
113
+ console.error(`[analytica] delivery failed with status ${response.status}`);
114
+ return false;
115
+ }
116
+ return true;
117
+ } catch (error) {
118
+ const message = error instanceof Error ? error.message : 'unknown error';
119
+ console.error(`[analytica] delivery failed: ${message}`);
120
+ return false;
121
+ }
122
+ }
123
+
124
+ async flush() {
125
+ await Promise.allSettled(this.pending);
126
+ if (this.pending.size > 0) return this.flush();
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ export {
132
+ ANALYTICA_EVENT_URL,
133
+ DEFAULT_ANALYTICA_EVENT_PREFIX,
134
+ DEFAULT_ANALYTICA_PAGE_LOCATION,
135
+ AnalyticaEvents,
136
+ analyticaEventName,
137
+ };