nexus-agentd 0.2.5 → 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 (61) hide show
  1. package/README.md +359 -250
  2. package/dist/a2a/runtime.d.ts +7 -1
  3. package/dist/a2a/runtime.js +290 -17
  4. package/dist/acp/runtime.d.ts +5 -1
  5. package/dist/acp/runtime.js +88 -39
  6. package/dist/artifact-store.d.ts +162 -0
  7. package/dist/artifact-store.js +1081 -0
  8. package/dist/cli.js +13 -14
  9. package/dist/completion-contract.js +1 -1
  10. package/dist/config.js +45 -5
  11. package/dist/control-plane-validation.d.ts +54 -0
  12. package/dist/control-plane-validation.js +152 -0
  13. package/dist/control-plane.d.ts +9 -32
  14. package/dist/control-plane.js +52 -158
  15. package/dist/drivers/index.js +12 -0
  16. package/dist/drivers/pi.js +1 -1
  17. package/dist/drivers/stdio.d.ts +4 -1
  18. package/dist/drivers/stdio.js +135 -19
  19. package/dist/events.d.ts +9 -0
  20. package/dist/events.js +17 -4
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +33 -6
  23. package/dist/process-tree.js +15 -6
  24. package/dist/run-store.d.ts +57 -7
  25. package/dist/run-store.js +188 -23
  26. package/dist/server/auth-policy.d.ts +27 -0
  27. package/dist/server/auth-policy.js +143 -0
  28. package/dist/server/connection.d.ts +8 -0
  29. package/dist/server/connection.js +67 -0
  30. package/dist/server/diagnostics.d.ts +40 -0
  31. package/dist/server/diagnostics.js +130 -0
  32. package/dist/server/metrics.d.ts +51 -0
  33. package/dist/server/metrics.js +56 -0
  34. package/dist/server/quota.d.ts +59 -0
  35. package/dist/server/quota.js +176 -0
  36. package/dist/server/validation.d.ts +29 -0
  37. package/dist/server/validation.js +221 -0
  38. package/dist/server.d.ts +3 -0
  39. package/dist/server.js +682 -418
  40. package/dist/session/admin-runs.d.ts +73 -0
  41. package/dist/session/admin-runs.js +297 -0
  42. package/dist/session/admission.d.ts +32 -0
  43. package/dist/session/admission.js +62 -0
  44. package/dist/session/readiness.d.ts +33 -0
  45. package/dist/session/readiness.js +174 -0
  46. package/dist/session-contract.d.ts +1 -0
  47. package/dist/session.d.ts +64 -7
  48. package/dist/session.js +321 -159
  49. package/dist/sse-writer.d.ts +7 -0
  50. package/dist/sse-writer.js +60 -0
  51. package/dist/types.d.ts +44 -2
  52. package/dist/types.js +1 -0
  53. package/dist/webui/app/sources.js +18 -10
  54. package/dist/webui/index.js +11 -11
  55. package/dist/webui/markup.js +69 -66
  56. package/dist/webui/styles.js +791 -616
  57. package/dist/workspace.d.ts +30 -0
  58. package/dist/workspace.js +67 -2
  59. package/examples/client.mjs +102 -0
  60. package/nexus-agentd.example.json +21 -2
  61. package/package.json +6 -3
@@ -0,0 +1,1081 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod as fsChmod, lstat as fsLstat, mkdir as fsMkdir, open as fsOpen, readdir as fsReaddir, readFile as fsReadFile, rename as fsRename, rm as fsRm, unlink as fsUnlink, writeFile as fsWriteFile } from 'node:fs/promises';
3
+ import { performance } from 'node:perf_hooks';
4
+ import path from 'node:path';
5
+ const DAY_MS = 24 * 60 * 60 * 1000;
6
+ const DEFAULT_MAX_ARTIFACT_BYTES = 12 * 1024 * 1024;
7
+ const DEFAULT_MAX_ARTIFACTS_PER_RUN = 32;
8
+ const DEFAULT_MAX_TOTAL_BYTES = 512 * 1024 * 1024;
9
+ const DEFAULT_MAX_QUEUED_BYTES = 64 * 1024 * 1024;
10
+ const DEFAULT_RETENTION_DAYS = 30;
11
+ const MAX_FILENAME_CHARS = 180;
12
+ const MAX_METADATA_CHARS = 16 * 1024;
13
+ const MAX_DESCRIPTION_CHARS = 8 * 1024;
14
+ const MAX_TEXT_CHARS = 2 * 1024 * 1024;
15
+ /**
16
+ * Durable payload store for run artifacts.
17
+ *
18
+ * The store keeps an index in memory and a small manifest per run. Payloads
19
+ * are written into a hashed run directory with hashed file names; incoming
20
+ * artifact ids, URLs, and filenames are never used as filesystem paths.
21
+ */
22
+ export class ArtifactStore {
23
+ rootDir;
24
+ maxArtifactBytes;
25
+ maxArtifactsPerRun;
26
+ maxTotalBytes;
27
+ maxQueuedBytes;
28
+ retentionDays;
29
+ now;
30
+ io;
31
+ onViewsChanged;
32
+ isRunActive;
33
+ entries = new Map();
34
+ runIds = new Set();
35
+ pending = new Map();
36
+ inFlight = new Map();
37
+ pendingDeletes = new Set();
38
+ queuedBytes = 0;
39
+ totalBytes = 0;
40
+ activeDrain;
41
+ initialized = false;
42
+ lastError;
43
+ lastWriteMs = 0;
44
+ writeFailures = 0;
45
+ constructor(rootDir, options = {}) {
46
+ this.rootDir = path.resolve(options.rootDir || options.artifactRootDir || rootDir);
47
+ this.maxArtifactBytes = Math.min(DEFAULT_MAX_ARTIFACT_BYTES, positiveInteger(options.maxArtifactBytes, DEFAULT_MAX_ARTIFACT_BYTES));
48
+ this.maxArtifactsPerRun = positiveInteger(options.maxArtifactsPerRun, DEFAULT_MAX_ARTIFACTS_PER_RUN);
49
+ this.maxTotalBytes = nonNegativeInteger(options.maxTotalBytes ?? options.maxDiskBytes, DEFAULT_MAX_TOTAL_BYTES);
50
+ this.maxQueuedBytes = nonNegativeInteger(options.maxQueuedBytes, Math.min(DEFAULT_MAX_QUEUED_BYTES, Math.max(this.maxArtifactBytes, 4 * 1024 * 1024)));
51
+ this.retentionDays = nonNegativeNumber(options.retentionDays, DEFAULT_RETENTION_DAYS);
52
+ this.now = options.now || Date.now;
53
+ this.onViewsChanged = options.onViewsChanged;
54
+ this.isRunActive = options.isRunActive || (() => false);
55
+ this.io = {
56
+ mkdir: options.io?.mkdir || fsMkdir,
57
+ readFile: options.io?.readFile || fsReadFile,
58
+ writeFile: options.io?.writeFile || fsWriteFile,
59
+ rename: options.io?.rename || fsRename,
60
+ chmod: options.io?.chmod || fsChmod,
61
+ readdir: options.io?.readdir || fsReaddir,
62
+ lstat: options.io?.lstat || fsLstat,
63
+ unlink: options.io?.unlink || fsUnlink,
64
+ rm: options.io?.rm || fsRm,
65
+ open: options.io?.open || fsOpen
66
+ };
67
+ }
68
+ async init() {
69
+ if (this.initialized)
70
+ return;
71
+ await this.io.mkdir(this.rootDir, { recursive: true, mode: 0o700 });
72
+ await this.ensureDirectory(this.rootDir, false);
73
+ await this.cleanupTemps();
74
+ await this.loadManifests();
75
+ this.initialized = true;
76
+ await this.prune();
77
+ }
78
+ /**
79
+ * Reconcile metadata loaded from the legacy run JSON file with manifests
80
+ * found on disk. A run JSON file remains the source of truth for metadata;
81
+ * manifests only make payload bytes durable.
82
+ */
83
+ restoreRun(runId, views) {
84
+ this.assertInitialized();
85
+ this.runIds.add(runId);
86
+ const incoming = views.slice(0, this.maxArtifactsPerRun).map((view, index) => normalizeView(view, this.now(), `artifact-${index + 1}`));
87
+ const result = [];
88
+ for (const view of incoming) {
89
+ const key = entryKey(runId, view.id);
90
+ const loaded = this.entries.get(key);
91
+ if (loaded &&
92
+ loaded.filePath &&
93
+ loaded.view.storageStatus === 'available') {
94
+ const next = {
95
+ ...loaded.view,
96
+ ...view,
97
+ // Newer metadata from runs.json wins over stale manifest
98
+ // display fields while storage facts come from the file.
99
+ id: view.id,
100
+ storageStatus: 'available',
101
+ downloadable: true,
102
+ size: loaded.view.size,
103
+ sha256: loaded.view.sha256,
104
+ storedAt: loaded.view.storedAt,
105
+ expiresAt: loaded.view.expiresAt
106
+ };
107
+ loaded.view = next;
108
+ result.push(next);
109
+ }
110
+ else {
111
+ const next = {
112
+ ...view,
113
+ storageStatus: view.storageStatus === 'expired' ||
114
+ view.storageStatus === 'evicted' ||
115
+ view.storageStatus === 'failed'
116
+ ? view.storageStatus
117
+ : 'metadata_only',
118
+ downloadable: false,
119
+ storageError: view.storageError ||
120
+ (view.storageStatus === 'pending'
121
+ ? 'Payload was not recovered after gateway restart'
122
+ : undefined)
123
+ };
124
+ this.entries.set(key, { runId, id: view.id, view: next });
125
+ result.push(next);
126
+ }
127
+ }
128
+ // A manifest may contain entries whose JSON metadata was pruned. Keep
129
+ // those files until the normal run-dir prune; they are never exposed.
130
+ this.emit(runId, result);
131
+ return structuredClone(result);
132
+ }
133
+ /**
134
+ * Record protocol artifacts synchronously. Payload conversion and queue
135
+ * insertion happen in memory; disk IO is scheduled in the background.
136
+ */
137
+ recordArtifacts(runId, artifacts) {
138
+ this.assertInitialized();
139
+ this.runIds.add(runId);
140
+ const existing = this.viewsForRun(runId);
141
+ const byId = new Map(existing.map((view) => [view.id, view]));
142
+ const accepted = artifacts.slice(0, this.maxArtifactsPerRun);
143
+ for (let index = 0; index < accepted.length; index++) {
144
+ const artifact = accepted[index];
145
+ const normalized = normalizeArtifact(artifact, this.now(), index, safeId(artifact.id) || existing[index]?.id);
146
+ const old = byId.get(normalized.id);
147
+ const oldEntry = this.entries.get(entryKey(runId, normalized.id));
148
+ const payload = payloadForArtifact(artifact, this.maxArtifactBytes);
149
+ const baseView = {
150
+ ...normalized.view,
151
+ ...(old ? { createdAt: old.createdAt, expiresAt: old.expiresAt } : {}),
152
+ expiresAt: old?.expiresAt ?? this.now() + this.retentionDays * DAY_MS,
153
+ storageStatus: 'metadata_only',
154
+ downloadable: false,
155
+ storageError: payload.error
156
+ };
157
+ let next = baseView;
158
+ const oldPending = this.pending.get(runId)?.get(normalized.id);
159
+ const oldInFlight = this.inFlight.get(entryKey(runId, normalized.id));
160
+ if (payload.bytes &&
161
+ oldEntry?.view.storageStatus === 'available' &&
162
+ oldEntry.view.sha256 === sha256(payload.bytes)) {
163
+ next = {
164
+ ...oldEntry.view,
165
+ ...baseView,
166
+ storageStatus: 'available',
167
+ downloadable: true,
168
+ size: oldEntry.bytes,
169
+ sha256: oldEntry.view.sha256,
170
+ storedAt: oldEntry.view.storedAt,
171
+ storageError: undefined
172
+ };
173
+ }
174
+ else if (payload.bytes && oldPending?.bytes.equals(payload.bytes)) {
175
+ next = {
176
+ ...oldPending.view,
177
+ ...baseView,
178
+ storageStatus: 'pending',
179
+ downloadable: false,
180
+ storageError: undefined,
181
+ size: payload.bytes.length
182
+ };
183
+ oldPending.view = next;
184
+ }
185
+ else if (payload.bytes && oldInFlight?.bytes.equals(payload.bytes)) {
186
+ next = {
187
+ ...oldInFlight.view,
188
+ ...baseView,
189
+ storageStatus: 'pending',
190
+ downloadable: false,
191
+ storageError: undefined,
192
+ size: payload.bytes.length,
193
+ sha256: sha256(payload.bytes)
194
+ };
195
+ oldInFlight.view = next;
196
+ }
197
+ else if (payload.bytes) {
198
+ // A repeated protocol id replaces a pending snapshot but keeps
199
+ // the previous metadata until the replacement commits.
200
+ this.removePending(runId, normalized.id);
201
+ if (this.queuedBytes + payload.bytes.length > this.maxQueuedBytes) {
202
+ next = oldEntry?.view.storageStatus === 'available'
203
+ ? {
204
+ ...oldEntry.view,
205
+ ...baseView,
206
+ storageStatus: 'available',
207
+ downloadable: true,
208
+ size: oldEntry.bytes,
209
+ sha256: oldEntry.view.sha256,
210
+ storedAt: oldEntry.view.storedAt,
211
+ storageError: 'Artifact persistence queue budget exceeded'
212
+ }
213
+ : {
214
+ ...baseView,
215
+ storageStatus: 'metadata_only',
216
+ downloadable: false,
217
+ storageError: 'Artifact persistence queue budget exceeded'
218
+ };
219
+ }
220
+ else {
221
+ next = {
222
+ ...baseView,
223
+ storageStatus: 'pending',
224
+ downloadable: false,
225
+ storageError: undefined,
226
+ size: payload.bytes.length,
227
+ sha256: sha256(payload.bytes)
228
+ };
229
+ const pending = {
230
+ runId,
231
+ id: normalized.id,
232
+ view: next,
233
+ bytes: payload.bytes
234
+ };
235
+ this.addPending(pending);
236
+ }
237
+ }
238
+ else if (oldEntry?.view.storageStatus === 'available') {
239
+ // Metadata updates from a protocol stream can omit bytes after
240
+ // the first snapshot. Keep the durable payload downloadable.
241
+ next = {
242
+ ...oldEntry.view,
243
+ ...baseView,
244
+ storageStatus: 'available',
245
+ downloadable: true,
246
+ size: oldEntry.bytes,
247
+ sha256: oldEntry.view.sha256,
248
+ storedAt: oldEntry.view.storedAt,
249
+ storageError: payload.error
250
+ };
251
+ }
252
+ const entry = {
253
+ runId,
254
+ id: normalized.id,
255
+ view: next,
256
+ filePath: oldEntry?.filePath,
257
+ bytes: oldEntry?.bytes,
258
+ durableView: oldEntry?.durableView ||
259
+ (oldEntry?.view.storageStatus === 'available' ? oldEntry.view : undefined)
260
+ };
261
+ this.entries.set(entryKey(runId, normalized.id), entry);
262
+ byId.set(normalized.id, next);
263
+ }
264
+ const views = Array.from(byId.values()).slice(0, this.maxArtifactsPerRun);
265
+ this.emit(runId, views);
266
+ this.scheduleDrain();
267
+ return structuredClone(views);
268
+ }
269
+ /** Merge display metadata from a RunStore update without dropping storage state. */
270
+ mergeViews(runId, views, notify = true) {
271
+ this.assertInitialized();
272
+ this.runIds.add(runId);
273
+ const currentViews = this.viewsForRun(runId);
274
+ const current = new Map(currentViews.map((view) => [view.id, view]));
275
+ for (let index = 0; index < views.length && index < this.maxArtifactsPerRun; index++) {
276
+ const raw = views[index];
277
+ const incoming = normalizeView(raw.id ? raw : { ...raw, id: currentViews[index]?.id }, this.now(), `artifact-${index + 1}`);
278
+ const existing = current.get(incoming.id);
279
+ const merged = {
280
+ ...(existing || incoming),
281
+ ...stripUndefined(incoming),
282
+ id: incoming.id,
283
+ storageStatus: existing?.storageStatus || incoming.storageStatus,
284
+ downloadable: existing?.downloadable ?? incoming.downloadable,
285
+ size: existing?.size,
286
+ sha256: existing?.sha256,
287
+ createdAt: existing?.createdAt || incoming.createdAt,
288
+ storedAt: existing?.storedAt,
289
+ expiresAt: existing?.expiresAt,
290
+ storageError: existing?.storageError
291
+ };
292
+ current.set(incoming.id, merged);
293
+ const entry = this.entries.get(entryKey(runId, incoming.id));
294
+ if (entry)
295
+ entry.view = merged;
296
+ else
297
+ this.entries.set(entryKey(runId, incoming.id), { runId, id: incoming.id, view: merged });
298
+ }
299
+ const result = Array.from(current.values()).slice(0, this.maxArtifactsPerRun);
300
+ if (notify)
301
+ this.emit(runId, result);
302
+ return structuredClone(result);
303
+ }
304
+ views(runId) {
305
+ this.assertInitialized();
306
+ return structuredClone(this.viewsForRun(runId));
307
+ }
308
+ async readArtifact(runId, artifactId) {
309
+ this.assertInitialized();
310
+ const entry = this.entries.get(entryKey(runId, artifactId));
311
+ if (!entry || entry.view.storageStatus !== 'available' || !entry.filePath)
312
+ return undefined;
313
+ const runDir = this.runDirectory(runId);
314
+ try {
315
+ await this.ensureDirectory(runDir, false);
316
+ const runReal = await this.realDirectory(runDir);
317
+ const fileReal = await this.realPath(entry.filePath);
318
+ if (!isContained(runReal, fileReal))
319
+ return undefined;
320
+ const stat = await this.io.lstat(entry.filePath);
321
+ if (!stat.isFile() || stat.isSymbolicLink())
322
+ return undefined;
323
+ const bytes = await this.readRegularFile(entry.filePath, this.maxArtifactBytes);
324
+ if (!bytes || bytes.length > this.maxArtifactBytes)
325
+ return undefined;
326
+ if (entry.view.sha256 && sha256(bytes) !== entry.view.sha256)
327
+ return undefined;
328
+ return {
329
+ bytes,
330
+ filename: entry.view.filename || entry.view.name || `artifact-${artifactId}`,
331
+ mediaType: entry.view.mediaType
332
+ };
333
+ }
334
+ catch (error) {
335
+ if (isMissing(error))
336
+ return undefined;
337
+ throw error;
338
+ }
339
+ }
340
+ /** Queue removal of one run's own directory. Active runs are protected. */
341
+ removeRun(runId) {
342
+ this.assertInitialized();
343
+ if (this.isRunActive(runId))
344
+ return;
345
+ this.runIds.delete(runId);
346
+ for (const key of Array.from(this.entries.keys())) {
347
+ if (key.startsWith(`${runId}\u0000`)) {
348
+ const entry = this.entries.get(key);
349
+ this.totalBytes -= entry?.bytes || 0;
350
+ this.entries.delete(key);
351
+ }
352
+ }
353
+ const pending = this.pending.get(runId);
354
+ if (pending) {
355
+ for (const item of pending.values())
356
+ this.queuedBytes -= item.bytes.length;
357
+ this.pending.delete(runId);
358
+ }
359
+ this.pendingDeletes.add(runId);
360
+ this.scheduleDrain();
361
+ }
362
+ async flush() {
363
+ this.assertInitialized();
364
+ if (this.activeDrain)
365
+ await this.activeDrain;
366
+ if (this.pending.size || this.pendingDeletes.size) {
367
+ // A previous background attempt may have failed. Keep the
368
+ // snapshot dirty and let an explicit flush retry it.
369
+ this.lastError = undefined;
370
+ await this.runDrain();
371
+ }
372
+ if (this.lastError)
373
+ throw this.lastError;
374
+ }
375
+ get queuedByteCount() {
376
+ return this.queuedBytes;
377
+ }
378
+ get storedByteCount() {
379
+ return this.totalBytes;
380
+ }
381
+ stats() {
382
+ const pendingWrites = Array.from(this.pending.values())
383
+ .reduce((total, queue) => total + queue.size, this.inFlight.size + this.pendingDeletes.size);
384
+ return {
385
+ totalPayloadBytes: this.totalBytes,
386
+ payloadBytes: this.totalBytes,
387
+ queuedBytes: this.queuedBytes,
388
+ pendingWrites,
389
+ retainedPayloads: Array.from(this.entries.values())
390
+ .filter((entry) => entry.view.storageStatus === 'available').length,
391
+ retainedRuns: this.runIds.size,
392
+ lastWriteMs: this.lastWriteMs,
393
+ writeFailures: this.writeFailures,
394
+ maxTotalBytes: this.maxTotalBytes,
395
+ maxQueuedBytes: this.maxQueuedBytes
396
+ };
397
+ }
398
+ async runDrain() {
399
+ if (this.activeDrain)
400
+ return this.activeDrain;
401
+ const drain = this.drainLoop()
402
+ .catch((error) => {
403
+ this.lastError = error;
404
+ this.writeFailures++;
405
+ throw error;
406
+ })
407
+ .finally(() => {
408
+ this.activeDrain = undefined;
409
+ });
410
+ this.activeDrain = drain;
411
+ return drain;
412
+ }
413
+ scheduleDrain() {
414
+ if (!this.initialized || this.activeDrain)
415
+ return;
416
+ void this.runDrain().catch((error) => {
417
+ console.error(JSON.stringify({
418
+ level: 'error',
419
+ event: 'artifact_write_failed',
420
+ root: this.rootDir,
421
+ message: error instanceof Error ? error.message : String(error)
422
+ }));
423
+ });
424
+ }
425
+ async drainLoop() {
426
+ this.lastError = undefined;
427
+ while (this.pending.size || this.pendingDeletes.size) {
428
+ const runId = this.pendingDeletes.values().next().value;
429
+ if (runId) {
430
+ this.pendingDeletes.delete(runId);
431
+ await this.deleteRunDirectory(runId);
432
+ continue;
433
+ }
434
+ const batchEntry = this.pending.entries().next().value;
435
+ if (!batchEntry)
436
+ continue;
437
+ const [batchRunId, batch] = batchEntry;
438
+ this.pending.delete(batchRunId);
439
+ for (const item of batch.values()) {
440
+ const key = entryKey(item.runId, item.id);
441
+ this.inFlight.set(key, item);
442
+ try {
443
+ await this.writePending(item);
444
+ }
445
+ catch (error) {
446
+ // Requeue the item and every item after it. The view stays
447
+ // pending, so retrying flush after an IO failure is safe.
448
+ this.requeue(item);
449
+ let seen = false;
450
+ for (const rest of batch.values()) {
451
+ if (rest === item) {
452
+ seen = true;
453
+ continue;
454
+ }
455
+ if (seen)
456
+ this.requeue(rest);
457
+ }
458
+ this.inFlight.delete(key);
459
+ throw error;
460
+ }
461
+ finally {
462
+ this.inFlight.delete(key);
463
+ }
464
+ }
465
+ }
466
+ }
467
+ async writePending(item) {
468
+ const startedAt = performance.now();
469
+ const key = entryKey(item.runId, item.id);
470
+ let entry = this.entries.get(key);
471
+ if (!entry)
472
+ return;
473
+ const runDir = this.runDirectory(item.runId);
474
+ await this.io.mkdir(runDir, { recursive: true, mode: 0o700 });
475
+ await this.ensureDirectory(runDir, false);
476
+ const runReal = await this.realDirectory(runDir);
477
+ if (!isContained(await this.realDirectory(this.rootDir), runReal)) {
478
+ throw new Error('Artifact run directory escaped the artifact root');
479
+ }
480
+ const filePath = this.payloadPath(item.runId, item.id);
481
+ const fileRealParent = path.dirname(filePath);
482
+ if (!isContained(runReal, fileRealParent))
483
+ throw new Error('Invalid artifact payload path');
484
+ const delta = item.bytes.length - (entry.bytes || 0);
485
+ const previousView = entry.durableView;
486
+ const previousPath = entry.filePath;
487
+ const previousBytes = entry.bytes;
488
+ const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
489
+ try {
490
+ await this.ensureBudget(item.runId, delta, entry);
491
+ await this.io.writeFile(temp, item.bytes, { flag: 'wx', mode: 0o600 });
492
+ await this.io.chmod(temp, 0o600).catch(() => undefined);
493
+ await this.io.rename(temp, filePath);
494
+ await this.io.chmod(filePath, 0o600).catch(() => undefined);
495
+ // A synchronous record can replace the map entry while this write
496
+ // is blocked on a slow filesystem. Commit against the current map
497
+ // entry so the latest metadata receives the available status.
498
+ const currentEntry = this.entries.get(key);
499
+ if (!currentEntry)
500
+ return;
501
+ entry = currentEntry;
502
+ const nextView = {
503
+ ...item.view,
504
+ storageStatus: 'available',
505
+ downloadable: true,
506
+ size: item.bytes.length,
507
+ sha256: sha256(item.bytes),
508
+ storedAt: this.now(),
509
+ storageError: undefined
510
+ };
511
+ entry.view = nextView;
512
+ entry.filePath = filePath;
513
+ entry.durableView = nextView;
514
+ this.totalBytes += delta;
515
+ entry.bytes = item.bytes.length;
516
+ await this.writeManifest(item.runId);
517
+ this.emit(item.runId, this.viewsForRun(item.runId));
518
+ this.lastWriteMs = Math.max(0, performance.now() - startedAt);
519
+ }
520
+ catch (error) {
521
+ await this.io.unlink(temp).catch(() => undefined);
522
+ if (error instanceof ArtifactBudgetError) {
523
+ const currentEntry = this.entries.get(key);
524
+ if (currentEntry)
525
+ entry = currentEntry;
526
+ if (previousPath && previousView?.storageStatus === 'available') {
527
+ entry.view = previousView;
528
+ entry.filePath = previousPath;
529
+ entry.bytes = previousBytes;
530
+ entry.durableView = previousView;
531
+ }
532
+ else {
533
+ entry.view = {
534
+ ...item.view,
535
+ storageStatus: 'metadata_only',
536
+ downloadable: false,
537
+ storageError: error.message
538
+ };
539
+ entry.filePath = undefined;
540
+ entry.bytes = undefined;
541
+ await this.writeManifest(item.runId);
542
+ }
543
+ this.emit(item.runId, this.viewsForRun(item.runId));
544
+ return;
545
+ }
546
+ throw error;
547
+ }
548
+ finally {
549
+ this.queuedBytes -= item.bytes.length;
550
+ }
551
+ }
552
+ async ensureBudget(runId, delta, replacing) {
553
+ if (delta <= 0 || this.totalBytes + delta <= this.maxTotalBytes)
554
+ return;
555
+ const candidates = Array.from(this.entries.values())
556
+ .filter((entry) => entry.view.storageStatus === 'available')
557
+ .filter((entry) => entry !== replacing)
558
+ .filter((entry) => !this.isRunActive(entry.runId))
559
+ .sort((left, right) => left.view.createdAt - right.view.createdAt);
560
+ for (const candidate of candidates) {
561
+ if (this.totalBytes + delta <= this.maxTotalBytes)
562
+ break;
563
+ await this.evict(candidate, 'disk_budget');
564
+ }
565
+ if (this.totalBytes + delta > this.maxTotalBytes) {
566
+ throw new ArtifactBudgetError('Artifact disk budget exceeded');
567
+ }
568
+ }
569
+ async evict(entry, reason) {
570
+ if (this.isRunActive(entry.runId))
571
+ return;
572
+ if (entry.filePath)
573
+ await this.safeUnlink(entry.filePath);
574
+ this.totalBytes -= entry.bytes || 0;
575
+ entry.bytes = undefined;
576
+ entry.filePath = undefined;
577
+ entry.durableView = undefined;
578
+ entry.view = {
579
+ ...entry.view,
580
+ storageStatus: 'evicted',
581
+ downloadable: false,
582
+ storageError: reason
583
+ };
584
+ await this.writeManifest(entry.runId);
585
+ this.emit(entry.runId, this.viewsForRun(entry.runId));
586
+ }
587
+ async prune() {
588
+ const cutoff = this.now() - this.retentionDays * DAY_MS;
589
+ const entries = Array.from(this.entries.values())
590
+ .filter((entry) => entry.view.storageStatus === 'available')
591
+ .filter((entry) => !this.isRunActive(entry.runId))
592
+ .sort((left, right) => left.view.createdAt - right.view.createdAt);
593
+ for (const entry of entries) {
594
+ if (entry.view.createdAt <= cutoff)
595
+ await this.expire(entry);
596
+ }
597
+ for (const entry of Array.from(this.entries.values()).sort((left, right) => left.view.createdAt - right.view.createdAt)) {
598
+ if (this.totalBytes <= this.maxTotalBytes)
599
+ break;
600
+ if (entry.view.storageStatus !== 'available' || this.isRunActive(entry.runId))
601
+ continue;
602
+ await this.evict(entry, 'disk_budget');
603
+ }
604
+ }
605
+ async expire(entry) {
606
+ if (this.isRunActive(entry.runId))
607
+ return;
608
+ if (entry.filePath)
609
+ await this.safeUnlink(entry.filePath);
610
+ this.totalBytes -= entry.bytes || 0;
611
+ entry.bytes = undefined;
612
+ entry.filePath = undefined;
613
+ entry.durableView = undefined;
614
+ entry.view = {
615
+ ...entry.view,
616
+ storageStatus: 'expired',
617
+ downloadable: false,
618
+ storageError: 'Artifact retention period elapsed'
619
+ };
620
+ await this.writeManifest(entry.runId);
621
+ this.emit(entry.runId, this.viewsForRun(entry.runId));
622
+ }
623
+ async deleteRunDirectory(runId) {
624
+ if (this.isRunActive(runId))
625
+ return;
626
+ const directory = this.runDirectory(runId);
627
+ try {
628
+ await this.ensureDirectory(directory, false);
629
+ await this.io.rm(directory, { recursive: true, force: true });
630
+ }
631
+ catch (error) {
632
+ if (!isMissing(error))
633
+ throw error;
634
+ }
635
+ }
636
+ async writeManifest(runId) {
637
+ const directory = this.runDirectory(runId);
638
+ await this.io.mkdir(directory, { recursive: true, mode: 0o700 });
639
+ await this.ensureDirectory(directory, false);
640
+ const artifacts = this.viewsForRun(runId)
641
+ .map((view) => {
642
+ const entry = this.entries.get(entryKey(runId, view.id));
643
+ if (!entry?.filePath || view.storageStatus !== 'available')
644
+ return undefined;
645
+ return {
646
+ id: view.id,
647
+ file: path.basename(entry.filePath),
648
+ view: structuredClone(view)
649
+ };
650
+ })
651
+ .filter((value) => Boolean(value));
652
+ const manifest = { schemaVersion: 1, runId, artifacts };
653
+ const target = this.manifestPath(runId);
654
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
655
+ try {
656
+ await this.io.writeFile(temp, `${JSON.stringify(manifest, null, 2)}\n`, {
657
+ encoding: 'utf8',
658
+ flag: 'wx',
659
+ mode: 0o600
660
+ });
661
+ await this.io.chmod(temp, 0o600).catch(() => undefined);
662
+ await this.io.rename(temp, target);
663
+ await this.io.chmod(target, 0o600).catch(() => undefined);
664
+ }
665
+ finally {
666
+ await this.io.unlink(temp).catch(() => undefined);
667
+ }
668
+ }
669
+ async loadManifests() {
670
+ let children;
671
+ try {
672
+ children = await this.io.readdir(this.rootDir, { withFileTypes: true });
673
+ }
674
+ catch (error) {
675
+ if (isMissing(error))
676
+ return;
677
+ throw error;
678
+ }
679
+ for (const child of children) {
680
+ const name = String(child.name || '');
681
+ if (!/^[a-f0-9]{32}$/.test(name))
682
+ continue;
683
+ const directory = path.join(this.rootDir, name);
684
+ try {
685
+ await this.ensureDirectory(directory, false);
686
+ const manifestPath = path.join(directory, 'manifest.json');
687
+ const raw = await this.io.readFile(manifestPath, 'utf8');
688
+ const parsed = JSON.parse(String(raw));
689
+ if (!validManifest(parsed) || hashId(parsed.runId, 32) !== name)
690
+ continue;
691
+ for (const item of parsed.artifacts) {
692
+ if (!validManifestArtifact(item, parsed.runId))
693
+ continue;
694
+ const filePath = path.join(directory, item.file);
695
+ if (path.basename(filePath) !== item.file)
696
+ continue;
697
+ const stat = await this.io.lstat(filePath);
698
+ if (!stat.isFile() || stat.isSymbolicLink())
699
+ continue;
700
+ const bytes = Number(item.view.size);
701
+ if (!Number.isFinite(bytes) || bytes < 0 || bytes > this.maxArtifactBytes)
702
+ continue;
703
+ const view = {
704
+ ...item.view,
705
+ storageStatus: 'available',
706
+ downloadable: true
707
+ };
708
+ this.entries.set(entryKey(parsed.runId, item.id), {
709
+ runId: parsed.runId,
710
+ id: item.id,
711
+ view,
712
+ filePath,
713
+ bytes
714
+ });
715
+ this.runIds.add(parsed.runId);
716
+ this.totalBytes += bytes;
717
+ }
718
+ }
719
+ catch (error) {
720
+ if (!isMissing(error)) {
721
+ console.error(JSON.stringify({
722
+ level: 'warn',
723
+ event: 'artifact_manifest_ignored',
724
+ path: directory,
725
+ message: error instanceof Error ? error.message : String(error)
726
+ }));
727
+ }
728
+ }
729
+ }
730
+ }
731
+ async cleanupTemps() {
732
+ let children;
733
+ try {
734
+ children = await this.io.readdir(this.rootDir, { withFileTypes: true });
735
+ }
736
+ catch (error) {
737
+ if (isMissing(error))
738
+ return;
739
+ throw error;
740
+ }
741
+ for (const child of children) {
742
+ const name = String(child.name || '');
743
+ if (!/^[a-f0-9]{32}$/.test(name))
744
+ continue;
745
+ const directory = path.join(this.rootDir, name);
746
+ try {
747
+ await this.ensureDirectory(directory, false);
748
+ const files = await this.io.readdir(directory, { withFileTypes: true });
749
+ for (const file of files) {
750
+ if (String(file.name).includes('.tmp')) {
751
+ const target = path.join(directory, String(file.name));
752
+ if (path.basename(target) === String(file.name))
753
+ await this.safeUnlink(target);
754
+ }
755
+ }
756
+ }
757
+ catch (error) {
758
+ if (!isMissing(error))
759
+ continue;
760
+ }
761
+ }
762
+ }
763
+ async ensureDirectory(directory, create) {
764
+ if (create)
765
+ await this.io.mkdir(directory, { recursive: true, mode: 0o700 });
766
+ const stat = await this.io.lstat(directory);
767
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
768
+ throw new Error('Artifact storage path is not a regular directory');
769
+ }
770
+ await this.io.chmod(directory, 0o700).catch(() => undefined);
771
+ }
772
+ async realDirectory(directory) {
773
+ const target = await this.realPath(directory);
774
+ return target;
775
+ }
776
+ async realPath(target) {
777
+ // fs.realpath is intentionally reached through the native module only;
778
+ // computed paths are hashed and are checked with lstat before use.
779
+ const { realpath } = await import('node:fs/promises');
780
+ return realpath(target);
781
+ }
782
+ async readRegularFile(filePath, maxBytes) {
783
+ // Open/fstat/read avoids returning a directory and uses O_NOFOLLOW on
784
+ // platforms that expose it. The lstat check above remains necessary on
785
+ // Windows where O_NOFOLLOW is unavailable.
786
+ const constants = (await import('node:fs')).constants;
787
+ const noFollow = constants.O_NOFOLLOW || 0;
788
+ let handle;
789
+ try {
790
+ handle = await this.io.open(filePath, noFollow ? (constants.O_RDONLY | noFollow) : 'r');
791
+ const stat = typeof handle.stat === 'function' ? await handle.stat() : undefined;
792
+ if (stat && (!stat.isFile() || Number(stat.size) > maxBytes))
793
+ return undefined;
794
+ if (typeof handle.read !== 'function') {
795
+ // Keep custom test IO shims usable while bounding the production
796
+ // path above with FileHandle.read().
797
+ const bytes = Buffer.from(await handle.readFile());
798
+ return bytes.length <= maxBytes ? bytes : undefined;
799
+ }
800
+ const buffer = Buffer.alloc(maxBytes + 1);
801
+ let offset = 0;
802
+ while (offset < buffer.length) {
803
+ const result = await handle.read(buffer, offset, buffer.length - offset, offset);
804
+ const bytesRead = Number(result?.bytesRead) || 0;
805
+ if (bytesRead <= 0)
806
+ break;
807
+ offset += bytesRead;
808
+ }
809
+ return buffer.subarray(0, offset);
810
+ }
811
+ finally {
812
+ await handle?.close?.().catch(() => undefined);
813
+ }
814
+ }
815
+ async safeUnlink(filePath) {
816
+ try {
817
+ const stat = await this.io.lstat(filePath);
818
+ if (stat.isSymbolicLink() || !stat.isFile())
819
+ return;
820
+ await this.io.unlink(filePath);
821
+ }
822
+ catch (error) {
823
+ if (!isMissing(error))
824
+ throw error;
825
+ }
826
+ }
827
+ runDirectory(runId) {
828
+ return path.join(this.rootDir, hashId(runId, 32));
829
+ }
830
+ manifestPath(runId) {
831
+ return path.join(this.runDirectory(runId), 'manifest.json');
832
+ }
833
+ payloadPath(runId, artifactId) {
834
+ return path.join(this.runDirectory(runId), `${hashId(artifactId, 64)}.bin`);
835
+ }
836
+ viewsForRun(runId) {
837
+ return Array.from(this.entries.values())
838
+ .filter((entry) => entry.runId === runId)
839
+ .sort((left, right) => left.view.createdAt - right.view.createdAt)
840
+ .map((entry) => entry.view);
841
+ }
842
+ addPending(item) {
843
+ let queue = this.pending.get(item.runId);
844
+ if (!queue) {
845
+ queue = new Map();
846
+ this.pending.set(item.runId, queue);
847
+ }
848
+ const old = queue.get(item.id);
849
+ if (old)
850
+ this.queuedBytes -= old.bytes.length;
851
+ queue.set(item.id, item);
852
+ this.queuedBytes += item.bytes.length;
853
+ }
854
+ removePending(runId, artifactId) {
855
+ const queue = this.pending.get(runId);
856
+ const item = queue?.get(artifactId);
857
+ if (!item || !queue)
858
+ return;
859
+ this.queuedBytes -= item.bytes.length;
860
+ queue.delete(artifactId);
861
+ if (!queue.size)
862
+ this.pending.delete(runId);
863
+ }
864
+ requeue(item) {
865
+ const entry = this.entries.get(entryKey(item.runId, item.id));
866
+ if (!entry)
867
+ return;
868
+ entry.view = { ...entry.view, storageStatus: 'pending', storageError: undefined };
869
+ this.addPending(item);
870
+ }
871
+ emit(runId, views) {
872
+ this.onViewsChanged?.(runId, structuredClone(views));
873
+ }
874
+ assertInitialized() {
875
+ if (!this.initialized)
876
+ throw new Error('ArtifactStore is not initialized');
877
+ }
878
+ }
879
+ export class ArtifactBudgetError extends Error {
880
+ }
881
+ export function artifactStorePathForRunStore(runStorePath) {
882
+ return `${path.resolve(runStorePath)}.artifacts`;
883
+ }
884
+ function normalizeArtifact(artifact, now, index, forcedId) {
885
+ const id = safeId(artifact.id) || forcedId || randomUUID();
886
+ const filename = safeFilename(artifact.filename || artifact.name || `artifact-${index + 1}`) || `artifact-${id.slice(0, 12)}`;
887
+ const name = clipString(artifact.name || filename, MAX_FILENAME_CHARS);
888
+ const mediaType = safeMediaType(artifact.mediaType);
889
+ const metadata = safeMetadata(artifact.metadata);
890
+ const expiresAt = now + DEFAULT_RETENTION_DAYS * DAY_MS;
891
+ const view = {
892
+ id,
893
+ name,
894
+ description: clipString(artifact.description, MAX_DESCRIPTION_CHARS),
895
+ url: safeUrl(artifact.url),
896
+ filename,
897
+ mediaType,
898
+ metadata,
899
+ storageStatus: 'metadata_only',
900
+ downloadable: false,
901
+ createdAt: now,
902
+ expiresAt
903
+ };
904
+ return { id, view };
905
+ }
906
+ function normalizeView(value, now, fallback) {
907
+ const id = safeId(value.id) || randomUUID();
908
+ const filename = safeFilename(value.filename || value.name || fallback) || fallback;
909
+ const incoming = value;
910
+ return {
911
+ ...structuredClone(value),
912
+ id,
913
+ name: clipString(value.name || filename, MAX_FILENAME_CHARS),
914
+ description: clipString(value.description, MAX_DESCRIPTION_CHARS),
915
+ url: safeUrl(value.url),
916
+ filename,
917
+ mediaType: safeMediaType(value.mediaType),
918
+ metadata: safeMetadata(value.metadata),
919
+ storageStatus: incoming.storageStatus || 'metadata_only',
920
+ downloadable: incoming.downloadable === true && incoming.storageStatus === 'available',
921
+ createdAt: Number.isFinite(incoming.createdAt) ? incoming.createdAt : now,
922
+ expiresAt: Number.isFinite(incoming.expiresAt)
923
+ ? incoming.expiresAt
924
+ : now + DEFAULT_RETENTION_DAYS * DAY_MS
925
+ };
926
+ }
927
+ function payloadForArtifact(artifact, maxBytes) {
928
+ try {
929
+ if (typeof artifact.bytesBase64 === 'string') {
930
+ const normalized = artifact.bytesBase64.replace(/\s+/g, '');
931
+ if (!normalized ||
932
+ normalized.length % 4 === 1 ||
933
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
934
+ return { error: 'Invalid base64 artifact payload' };
935
+ }
936
+ if (normalized.length > Math.ceil(maxBytes * 4 / 3) + 8) {
937
+ return { error: `Artifact exceeds the ${maxBytes} byte limit` };
938
+ }
939
+ const bytes = Buffer.from(normalized, 'base64');
940
+ const canonical = bytes.toString('base64').replace(/=+$/, '');
941
+ if (canonical !== normalized.replace(/=+$/, '')) {
942
+ return { error: 'Invalid base64 artifact payload' };
943
+ }
944
+ if (bytes.length > maxBytes)
945
+ return { error: `Artifact exceeds the ${maxBytes} byte limit` };
946
+ return { bytes };
947
+ }
948
+ if (typeof artifact.text === 'string') {
949
+ if (artifact.text.length > MAX_TEXT_CHARS) {
950
+ return { error: 'Text artifact exceeds the text snapshot limit' };
951
+ }
952
+ const bytes = Buffer.from(artifact.text, 'utf8');
953
+ return bytes.length <= maxBytes
954
+ ? { bytes }
955
+ : { error: `Artifact exceeds the ${maxBytes} byte limit` };
956
+ }
957
+ if (artifact.data !== undefined) {
958
+ const data = artifact.data;
959
+ let bytes;
960
+ if (Buffer.isBuffer(data))
961
+ bytes = Buffer.from(data);
962
+ else if (data instanceof Uint8Array)
963
+ bytes = Buffer.from(data);
964
+ else if (data instanceof ArrayBuffer)
965
+ bytes = Buffer.from(data);
966
+ else if (data &&
967
+ typeof data === 'object' &&
968
+ data.type === 'Buffer' &&
969
+ Array.isArray(data.data)) {
970
+ bytes = Buffer.from(data.data);
971
+ }
972
+ else if (typeof data === 'string')
973
+ bytes = Buffer.from(data, 'utf8');
974
+ else {
975
+ const serialized = JSON.stringify(data);
976
+ if (serialized === undefined)
977
+ return { error: 'Artifact data is not serializable' };
978
+ bytes = Buffer.from(serialized, 'utf8');
979
+ }
980
+ if (bytes.length > maxBytes)
981
+ return { error: `Artifact exceeds the ${maxBytes} byte limit` };
982
+ return { bytes };
983
+ }
984
+ return {};
985
+ }
986
+ catch (error) {
987
+ return { error: `Artifact payload could not be serialized: ${errorMessage(error)}` };
988
+ }
989
+ }
990
+ function validManifest(value) {
991
+ if (!value || typeof value !== 'object' || Array.isArray(value))
992
+ return false;
993
+ const candidate = value;
994
+ return candidate.schemaVersion === 1 && typeof candidate.runId === 'string' && Array.isArray(candidate.artifacts);
995
+ }
996
+ function validManifestArtifact(value, runId) {
997
+ if (!value || typeof value !== 'object' || Array.isArray(value))
998
+ return false;
999
+ const candidate = value;
1000
+ return (typeof candidate.id === 'string' &&
1001
+ candidate.id.length > 0 &&
1002
+ typeof candidate.file === 'string' &&
1003
+ /^[a-f0-9]{64}\.bin$/.test(candidate.file) &&
1004
+ Boolean(candidate.view) &&
1005
+ candidate.view.id === candidate.id &&
1006
+ hashId(candidate.id, 64) === candidate.file.slice(0, 64) &&
1007
+ (candidate.view.storageStatus === 'available' || candidate.view.storageStatus === 'stored') &&
1008
+ (!candidate.view.url || typeof candidate.view.url === 'string') &&
1009
+ Boolean(runId));
1010
+ }
1011
+ function entryKey(runId, artifactId) {
1012
+ return `${runId}\u0000${artifactId}`;
1013
+ }
1014
+ function hashId(value, length) {
1015
+ return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, length);
1016
+ }
1017
+ function sha256(bytes) {
1018
+ return createHash('sha256').update(bytes).digest('hex');
1019
+ }
1020
+ function safeId(value) {
1021
+ const id = typeof value === 'string' ? value.trim() : '';
1022
+ return id && id.length <= 256 ? id : undefined;
1023
+ }
1024
+ function safeFilename(value) {
1025
+ const raw = typeof value === 'string' ? value : '';
1026
+ const basename = raw.split(/[\\/]/).pop() || raw;
1027
+ return basename
1028
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
1029
+ .replace(/^\.+$/, '_')
1030
+ .trim()
1031
+ .slice(0, MAX_FILENAME_CHARS);
1032
+ }
1033
+ function safeMediaType(value) {
1034
+ const mediaType = typeof value === 'string' ? value.trim() : '';
1035
+ return /^[\w!#$&^_.+\-/]{1,180}$/.test(mediaType) ? mediaType : undefined;
1036
+ }
1037
+ function safeUrl(value) {
1038
+ const url = typeof value === 'string' ? value.trim() : '';
1039
+ return url.length <= 4 * 1024 && /^(?:https?|file):/i.test(url) ? url : undefined;
1040
+ }
1041
+ function safeMetadata(value) {
1042
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1043
+ return undefined;
1044
+ try {
1045
+ const serialized = JSON.stringify(value);
1046
+ if (!serialized || serialized.length > MAX_METADATA_CHARS) {
1047
+ return { storageWarning: 'Artifact metadata omitted because it exceeded the metadata limit' };
1048
+ }
1049
+ return structuredClone(value);
1050
+ }
1051
+ catch {
1052
+ return { storageWarning: 'Artifact metadata omitted because it was not serializable' };
1053
+ }
1054
+ }
1055
+ function clipString(value, max) {
1056
+ if (typeof value !== 'string' || !value)
1057
+ return undefined;
1058
+ return value.length > max ? `${value.slice(0, max)}…` : value;
1059
+ }
1060
+ function stripUndefined(value) {
1061
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
1062
+ }
1063
+ function positiveInteger(value, fallback) {
1064
+ return Number.isFinite(value) && Number(value) > 0 ? Math.floor(Number(value)) : fallback;
1065
+ }
1066
+ function nonNegativeInteger(value, fallback) {
1067
+ return Number.isFinite(value) && Number(value) >= 0 ? Math.floor(Number(value)) : fallback;
1068
+ }
1069
+ function nonNegativeNumber(value, fallback) {
1070
+ return Number.isFinite(value) && Number(value) >= 0 ? Number(value) : fallback;
1071
+ }
1072
+ function isContained(root, target) {
1073
+ const relative = path.relative(root, target);
1074
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
1075
+ }
1076
+ function isMissing(error) {
1077
+ return error?.code === 'ENOENT';
1078
+ }
1079
+ function errorMessage(error) {
1080
+ return error instanceof Error ? error.message : String(error);
1081
+ }