@mono-agent/a2a-adapter 0.9.0 → 0.10.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.
@@ -0,0 +1,1325 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink, } from "node:fs/promises";
4
+ import { basename, dirname, resolve } from "node:path";
5
+ import { TaskState, } from "@a2a-js/sdk";
6
+ import { RequestMalformedError, } from "@a2a-js/sdk/server";
7
+ import { A2AProviderError } from "./errors.js";
8
+ export const A2A_IDEMPOTENCY_METADATA_KEY = "mono-agent.dev/a2a-idempotency/v1";
9
+ export const A2A_IDEMPOTENCY_SCHEMA_VERSION = 1;
10
+ export const A2A_IDEMPOTENCY_EXTENSION_URI = "https://mono-agent.dev/extensions/a2a-idempotency/v1";
11
+ const RECORD_SCHEMA_VERSION = 1;
12
+ const STORE_MANIFEST_SCHEMA_VERSION = 1;
13
+ const STORE_MANIFEST_FILE = "manifest.json";
14
+ const SLOTS_DIRECTORY = "slots";
15
+ const RECORD_FILE_PATTERN = /^[a-f0-9]{64}\.json$/u;
16
+ const SLOT_FILE_PATTERN = /^slot-([0-9]{1,7})\.json$/u;
17
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
18
+ const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,199}$/u;
19
+ const MAX_RECORD_BYTES = 4 * 1024 * 1024;
20
+ const ACTIVE_POLL_MS = 50;
21
+ const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
22
+ const MIN_RETENTION_MS = 60_000;
23
+ const MAX_RETENTION_MS = 365 * 24 * 60 * 60 * 1_000;
24
+ const DEFAULT_MAX_RECORDS = 10_000;
25
+ const MAX_RECORDS_LIMIT = 1_000_000;
26
+ const ERROR_MARKER = "[mono-agent:a2a-idempotency]";
27
+ export async function createIdempotentA2ARequestHandler(input) {
28
+ validateA2AProviderIdempotencyOptions(input.options);
29
+ const retentionMs = normalizeRetentionMs(input.options.retentionMs);
30
+ const maxRecords = normalizeMaxRecords(input.options.maxRecords);
31
+ const providerScope = idempotencyNamespaceHash(input.options.namespace);
32
+ const store = await FileIdempotencyStore.create(input.options.stateDir.trim(), retentionMs, maxRecords, providerScope);
33
+ return new IdempotentA2ARequestHandler(input.delegate, input.taskStore, store, providerScope, input.logger);
34
+ }
35
+ export function validateA2AProviderIdempotencyOptions(options) {
36
+ if (typeof options.stateDir !== "string" || options.stateDir.trim().length === 0) {
37
+ throw new A2AProviderError("invalid_config", "A2A durable idempotency requires a non-empty idempotency.stateDir.", { field: "idempotency.stateDir" });
38
+ }
39
+ idempotencyNamespaceHash(options.namespace);
40
+ normalizeRetentionMs(options.retentionMs);
41
+ normalizeMaxRecords(options.maxRecords);
42
+ }
43
+ export function guardUnsupportedA2AIdempotency(delegate) {
44
+ return new UnsupportedIdempotencyA2ARequestHandler(delegate);
45
+ }
46
+ export function normalizeA2AIdempotencyKey(value) {
47
+ const key = value.trim();
48
+ if (!IDEMPOTENCY_KEY_PATTERN.test(key)) {
49
+ throw new A2AProviderError("invalid_idempotency_key", "A2A idempotencyKey must be 1-200 ASCII letters, digits, or . _ : @ - and start with a letter or digit.", { field: "idempotencyKey" });
50
+ }
51
+ return key;
52
+ }
53
+ export function a2aIdempotencyEnvelope(key) {
54
+ return {
55
+ schemaVersion: A2A_IDEMPOTENCY_SCHEMA_VERSION,
56
+ key: normalizeA2AIdempotencyKey(key),
57
+ };
58
+ }
59
+ export function stableA2AMessageId(key) {
60
+ return `mono-idem-${sha256(normalizeA2AIdempotencyKey(key)).slice(0, 32)}`;
61
+ }
62
+ export function defaultA2AIdempotencyStateDir(cwd, namespace) {
63
+ const label = namespace.replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 48) || "a2a";
64
+ const digest = sha256(namespace).slice(0, 12);
65
+ return resolve(cwd, ".mono-agent", "a2a-idempotency", `${label}-${digest}`);
66
+ }
67
+ export function classifyA2AIdempotencyTransportError(reason) {
68
+ if (!reason.includes(ERROR_MARKER)) {
69
+ return undefined;
70
+ }
71
+ if (reason.includes(" idempotency_conflict ")) {
72
+ return "conflict";
73
+ }
74
+ if (reason.includes(" idempotency_capacity_exhausted ")) {
75
+ return "capacity_exhausted";
76
+ }
77
+ if (reason.includes(" idempotency_in_doubt ")) {
78
+ return "in_doubt";
79
+ }
80
+ if (reason.includes(" invalid_idempotency_key ")) {
81
+ return "invalid_key";
82
+ }
83
+ if (reason.includes(" idempotency_result_expired ")) {
84
+ return "result_expired";
85
+ }
86
+ if (reason.includes(" idempotency_unsupported ")) {
87
+ return "unsupported";
88
+ }
89
+ return undefined;
90
+ }
91
+ class DelegatingA2ARequestHandler {
92
+ delegate;
93
+ constructor(delegate) {
94
+ this.delegate = delegate;
95
+ }
96
+ getAgentCard() {
97
+ return this.delegate.getAgentCard();
98
+ }
99
+ getAuthenticatedExtendedAgentCard(params, context) {
100
+ return this.delegate.getAuthenticatedExtendedAgentCard(params, context);
101
+ }
102
+ sendMessage(params, context) {
103
+ return this.delegate.sendMessage(params, context);
104
+ }
105
+ async *sendMessageStream(params, context) {
106
+ yield* this.delegate.sendMessageStream(params, context);
107
+ }
108
+ getTask(params, context) {
109
+ return this.delegate.getTask(params, context);
110
+ }
111
+ cancelTask(params, context) {
112
+ return this.delegate.cancelTask(params, context);
113
+ }
114
+ createTaskPushNotificationConfig(params, context) {
115
+ return this.delegate.createTaskPushNotificationConfig(params, context);
116
+ }
117
+ getTaskPushNotificationConfig(params, context) {
118
+ return this.delegate.getTaskPushNotificationConfig(params, context);
119
+ }
120
+ listTaskPushNotificationConfigs(params, context) {
121
+ return this.delegate.listTaskPushNotificationConfigs(params, context);
122
+ }
123
+ deleteTaskPushNotificationConfig(params, context) {
124
+ return this.delegate.deleteTaskPushNotificationConfig(params, context);
125
+ }
126
+ resubscribe(params, context) {
127
+ return this.delegate.resubscribe(params, context);
128
+ }
129
+ listTasks(params, context) {
130
+ return this.delegate.listTasks(params, context);
131
+ }
132
+ }
133
+ class UnsupportedIdempotencyA2ARequestHandler extends DelegatingA2ARequestHandler {
134
+ async sendMessage(params, context) {
135
+ rejectUnsupportedIdempotencyEnvelope(params);
136
+ return await super.sendMessage(params, context);
137
+ }
138
+ async *sendMessageStream(params, context) {
139
+ rejectUnsupportedIdempotencyEnvelope(params);
140
+ yield* super.sendMessageStream(params, context);
141
+ }
142
+ }
143
+ class IdempotentA2ARequestHandler extends DelegatingA2ARequestHandler {
144
+ taskStore;
145
+ store;
146
+ providerScope;
147
+ logger;
148
+ activeRequests = new Map();
149
+ liveImmediateTasks = new Set();
150
+ constructor(delegate, taskStore, store, providerScope, logger) {
151
+ super(delegate);
152
+ this.taskStore = taskStore;
153
+ this.store = store;
154
+ this.providerScope = providerScope;
155
+ this.logger = logger;
156
+ }
157
+ async sendMessage(params, context) {
158
+ const admission = this.admissionFor(params, context);
159
+ if (admission === undefined) {
160
+ return await this.delegate.sendMessage(params, context);
161
+ }
162
+ const running = this.activeRequests.get(admission.keyHash);
163
+ if (running !== undefined) {
164
+ assertSameFingerprint(running.fingerprint, admission.fingerprint);
165
+ return await projectRuntimeResult(running, params.configuration);
166
+ }
167
+ const started = this.admitAndStart(admission, params, context);
168
+ const accepted = started.then((value) => value.accepted);
169
+ const terminal = started.then((value) => value.terminal);
170
+ // Either projection may be unused by a particular caller. Attach an
171
+ // observation handler without changing the promises returned to callers.
172
+ void accepted.catch(() => undefined);
173
+ void terminal.catch(() => undefined);
174
+ const runtime = {
175
+ fingerprint: admission.fingerprint,
176
+ accepted,
177
+ terminal,
178
+ };
179
+ this.activeRequests.set(admission.keyHash, runtime);
180
+ void terminal.then(() => {
181
+ const current = this.activeRequests.get(admission.keyHash);
182
+ if (current === runtime) {
183
+ this.activeRequests.delete(admission.keyHash);
184
+ }
185
+ }, () => {
186
+ const current = this.activeRequests.get(admission.keyHash);
187
+ if (current === runtime) {
188
+ this.activeRequests.delete(admission.keyHash);
189
+ }
190
+ });
191
+ return await projectRuntimeResult(runtime, params.configuration);
192
+ }
193
+ async *sendMessageStream(params, context) {
194
+ if (this.admissionFor(params, context) === undefined) {
195
+ yield* this.delegate.sendMessageStream(params, context);
196
+ return;
197
+ }
198
+ // An idempotent stream intentionally converges through the blocking send
199
+ // path. It yields the one authoritative task/message rather than replaying
200
+ // transient deltas, which cannot be reconstructed durably after restart.
201
+ const result = await this.sendMessage(params, context);
202
+ yield "status" in result
203
+ ? { payload: { $case: "task", value: result } }
204
+ : { payload: { $case: "message", value: result } };
205
+ }
206
+ admissionFor(params, context) {
207
+ const envelope = readEnvelope(params.metadata?.[A2A_IDEMPOTENCY_METADATA_KEY]);
208
+ if (envelope === undefined) {
209
+ return undefined;
210
+ }
211
+ if (!context.requestedExtensions?.includes(A2A_IDEMPOTENCY_EXTENSION_URI)) {
212
+ throw protocolIdempotencyError("invalid_idempotency_key", `The ${A2A_IDEMPOTENCY_EXTENSION_URI} extension must be requested through the A2A-Extensions service parameter.`);
213
+ }
214
+ context.addActivatedExtension(A2A_IDEMPOTENCY_EXTENSION_URI);
215
+ const tenant = context.tenant ?? params.tenant ?? "";
216
+ return {
217
+ keyHash: sha256(canonicalJson({ providerScope: this.providerScope, key: envelope.key })),
218
+ fingerprint: requestFingerprint(params, tenant, this.providerScope),
219
+ };
220
+ }
221
+ async admitAndStart(admission, params, context) {
222
+ let existing = await this.store.load(admission.keyHash);
223
+ if (existing !== undefined) {
224
+ const result = this.resultFromExisting(existing, admission.fingerprint);
225
+ return { accepted: result, terminal: Promise.resolve(result) };
226
+ }
227
+ const now = Date.now();
228
+ const activeCandidate = {
229
+ schemaVersion: RECORD_SCHEMA_VERSION,
230
+ keyHash: admission.keyHash,
231
+ fingerprint: admission.fingerprint,
232
+ status: "active",
233
+ createdAtMs: now,
234
+ updatedAtMs: now,
235
+ };
236
+ // This fsynced admission is deliberately before the responder/model call.
237
+ const active = await this.store.createActive(activeCandidate);
238
+ if (active === undefined) {
239
+ // A second provider process may share this directory. The exclusive
240
+ // record creation is the cross-process serialization boundary: the
241
+ // loser reads and reuses/fails closed, never invokes the responder.
242
+ existing = await this.store.load(admission.keyHash);
243
+ if (existing === undefined) {
244
+ throw storeError("A2A idempotency admission changed during exclusive creation; refusing execution.");
245
+ }
246
+ const result = this.resultFromExisting(existing, admission.fingerprint);
247
+ return { accepted: result, terminal: Promise.resolve(result) };
248
+ }
249
+ const result = await this.delegate.sendMessage(asImmediateExecutionRequest(params), context);
250
+ const taskId = taskIdFromResult(result);
251
+ if (isNonTerminalTask(result)) {
252
+ const accepted = {
253
+ ...active,
254
+ updatedAtMs: Date.now(),
255
+ ...(taskId === undefined ? {} : { taskId }),
256
+ acceptedResult: cloneResult(result),
257
+ };
258
+ await this.store.save(accepted);
259
+ if (taskId !== undefined) {
260
+ this.liveImmediateTasks.add(taskId);
261
+ return {
262
+ accepted: result,
263
+ terminal: this.monitorImmediateTask(accepted, context),
264
+ };
265
+ }
266
+ throw storeError("A2A immediate task did not include a task id; refusing an unmonitorable admission.");
267
+ }
268
+ await this.store.save({
269
+ schemaVersion: RECORD_SCHEMA_VERSION,
270
+ keyHash: active.keyHash,
271
+ fingerprint: active.fingerprint,
272
+ status: "completed",
273
+ createdAtMs: active.createdAtMs,
274
+ updatedAtMs: Date.now(),
275
+ slot: active.slot,
276
+ ...(taskId === undefined ? {} : { taskId }),
277
+ result: cloneResult(result),
278
+ });
279
+ return { accepted: result, terminal: Promise.resolve(result) };
280
+ }
281
+ resultFromExisting(record, fingerprint) {
282
+ assertSameFingerprint(record.fingerprint, fingerprint);
283
+ if (record.status === "completed") {
284
+ return cloneResult(record.result);
285
+ }
286
+ if (record.status === "tombstone") {
287
+ throw protocolIdempotencyError("idempotency_result_expired", "The durable terminal result was compacted after its retention horizon; the logical key remains permanently bound and will not be re-executed.");
288
+ }
289
+ if (record.taskId !== undefined
290
+ && record.acceptedResult !== undefined
291
+ && this.liveImmediateTasks.has(record.taskId)) {
292
+ return cloneResult(record.acceptedResult);
293
+ }
294
+ throw protocolIdempotencyError("idempotency_in_doubt", "This logical A2A dispatch was durably admitted, but its prior provider process did not record a terminal result. Refusing automatic re-execution.");
295
+ }
296
+ async monitorImmediateTask(record, context) {
297
+ const taskId = record.taskId;
298
+ if (taskId === undefined) {
299
+ throw storeError("A2A active idempotency record is missing taskId.");
300
+ }
301
+ try {
302
+ while (this.liveImmediateTasks.has(taskId)) {
303
+ const task = await this.taskStore.load(taskId, context);
304
+ if (task !== undefined && isTerminalState(task.status?.state)) {
305
+ await this.store.save({
306
+ schemaVersion: RECORD_SCHEMA_VERSION,
307
+ keyHash: record.keyHash,
308
+ fingerprint: record.fingerprint,
309
+ status: "completed",
310
+ createdAtMs: record.createdAtMs,
311
+ updatedAtMs: Date.now(),
312
+ slot: record.slot,
313
+ taskId,
314
+ result: cloneResult(task),
315
+ });
316
+ return task;
317
+ }
318
+ await unrefDelay(ACTIVE_POLL_MS);
319
+ }
320
+ throw storeError("A2A idempotent task monitoring ended before a terminal result was recorded.");
321
+ }
322
+ catch (error) {
323
+ this.logger?.error?.("A2A idempotency monitor failed; the admission remains fail-closed.", {
324
+ taskId,
325
+ reason: error instanceof Error ? error.message : String(error),
326
+ });
327
+ throw error;
328
+ }
329
+ finally {
330
+ this.liveImmediateTasks.delete(taskId);
331
+ }
332
+ }
333
+ }
334
+ class FileIdempotencyStore {
335
+ stateDir;
336
+ stateDirIdentity;
337
+ retentionMs;
338
+ maxRecords;
339
+ providerScope;
340
+ slotsDir;
341
+ slotsDirIdentity;
342
+ constructor(stateDir, stateDirIdentity, retentionMs, maxRecords, providerScope, slotsDir, slotsDirIdentity) {
343
+ this.stateDir = stateDir;
344
+ this.stateDirIdentity = stateDirIdentity;
345
+ this.retentionMs = retentionMs;
346
+ this.maxRecords = maxRecords;
347
+ this.providerScope = providerScope;
348
+ this.slotsDir = slotsDir;
349
+ this.slotsDirIdentity = slotsDirIdentity;
350
+ }
351
+ static async create(inputPath, retentionMs, maxRecords, providerScope) {
352
+ const stateDirectory = await ensurePrivateStateDir(resolve(inputPath));
353
+ const slotsDirectory = await ensurePrivateStateDir(resolve(stateDirectory.path, SLOTS_DIRECTORY));
354
+ const store = new FileIdempotencyStore(stateDirectory.path, stateDirectory.identity, retentionMs, maxRecords, providerScope, slotsDirectory.path, slotsDirectory.identity);
355
+ await store.ensureManifest();
356
+ await store.pruneExpiredTerminalRecords();
357
+ await store.reconcileOrphanSlots();
358
+ return store;
359
+ }
360
+ async load(keyHash) {
361
+ await this.assertDirectories();
362
+ assertKeyHash(keyHash);
363
+ const path = resolve(this.stateDir, `${keyHash}.json`);
364
+ let raw;
365
+ try {
366
+ raw = await secureReadPrivateFile(path, RECORD_FILE_PATTERN);
367
+ }
368
+ catch (error) {
369
+ if (isErrno(error, "ENOENT")) {
370
+ await this.assertDirectories();
371
+ return undefined;
372
+ }
373
+ throw storeError("Failed to read A2A idempotency state.", error);
374
+ }
375
+ let record;
376
+ try {
377
+ record = parseRecord(JSON.parse(raw.toString("utf8")), keyHash);
378
+ }
379
+ catch (error) {
380
+ throw storeError("A2A idempotency state is malformed; refusing automatic execution.", error);
381
+ }
382
+ await this.verifySlot(record.slot, keyHash);
383
+ if (record.status === "completed" && isExpired(record, this.retentionMs)) {
384
+ record = await this.compactExpiredRecord(record);
385
+ }
386
+ await this.assertDirectories();
387
+ return record;
388
+ }
389
+ async save(record) {
390
+ await this.assertDirectories();
391
+ assertKeyHash(record.keyHash);
392
+ await this.verifySlot(record.slot, record.keyHash);
393
+ const destination = resolve(this.stateDir, `${record.keyHash}.json`);
394
+ const temporary = resolve(this.stateDir, `.${record.keyHash}.${randomUUID()}.tmp`);
395
+ const contents = Buffer.from(`${canonicalJson(record)}\n`, "utf8");
396
+ if (contents.byteLength > MAX_RECORD_BYTES) {
397
+ throw storeError("A2A idempotency result exceeds the durable record limit.");
398
+ }
399
+ try {
400
+ await assertReplaceableRecord(destination);
401
+ const handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
402
+ try {
403
+ await handle.writeFile(contents);
404
+ await handle.chmod(0o600);
405
+ await handle.sync();
406
+ }
407
+ finally {
408
+ await handle.close();
409
+ }
410
+ await rename(temporary, destination);
411
+ await fsyncDirectory(this.stateDir, this.stateDirIdentity);
412
+ await this.assertDirectories();
413
+ }
414
+ catch (error) {
415
+ await unlink(temporary).catch(() => undefined);
416
+ throw storeError("Failed to persist A2A idempotency state.", error);
417
+ }
418
+ }
419
+ async createActive(record) {
420
+ await this.assertDirectories();
421
+ assertKeyHash(record.keyHash);
422
+ const slot = await this.allocateSlot(record.keyHash);
423
+ const admitted = { ...record, slot };
424
+ const destination = resolve(this.stateDir, `${record.keyHash}.json`);
425
+ const contents = Buffer.from(`${canonicalJson(admitted)}\n`, "utf8");
426
+ let handle;
427
+ try {
428
+ handle = await open(destination, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
429
+ }
430
+ catch (error) {
431
+ if (isErrno(error, "EEXIST")) {
432
+ await this.assertDirectories();
433
+ return undefined;
434
+ }
435
+ throw storeError("Failed to create the durable A2A idempotency admission.", error);
436
+ }
437
+ try {
438
+ await handle.writeFile(contents);
439
+ await handle.chmod(0o600);
440
+ await handle.sync();
441
+ }
442
+ catch (error) {
443
+ await handle.close().catch(() => undefined);
444
+ await unlink(destination).catch(() => undefined);
445
+ throw storeError("Failed to persist the durable A2A idempotency admission.", error);
446
+ }
447
+ try {
448
+ await handle.close();
449
+ await fsyncDirectory(this.stateDir, this.stateDirIdentity);
450
+ await this.verifySlot(slot, record.keyHash);
451
+ await this.assertDirectories();
452
+ return admitted;
453
+ }
454
+ catch (error) {
455
+ await handle.close().catch(() => undefined);
456
+ await unlink(destination).catch(() => undefined);
457
+ await fsyncDirectory(this.stateDir, this.stateDirIdentity).catch(() => undefined);
458
+ throw storeError("Failed to finalize the durable A2A idempotency admission.", error);
459
+ }
460
+ }
461
+ async assertDirectories() {
462
+ try {
463
+ await assertPrivateDirectoryIdentity(this.stateDir, this.stateDirIdentity);
464
+ await assertPrivateDirectoryIdentity(this.slotsDir, this.slotsDirIdentity);
465
+ // Re-check the parent after walking through it to the slots directory so
466
+ // a pathname replacement cannot splice two independently valid roots.
467
+ await assertPrivateDirectoryIdentity(this.stateDir, this.stateDirIdentity);
468
+ }
469
+ catch (error) {
470
+ throw storeError("A2A idempotency store directory identity changed; refusing filesystem access.", error);
471
+ }
472
+ }
473
+ async ensureManifest() {
474
+ await this.assertDirectories();
475
+ const path = resolve(this.stateDir, STORE_MANIFEST_FILE);
476
+ const expected = {
477
+ schemaVersion: STORE_MANIFEST_SCHEMA_VERSION,
478
+ maxRecords: this.maxRecords,
479
+ providerScope: this.providerScope,
480
+ };
481
+ let handle;
482
+ try {
483
+ handle = await open(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
484
+ await handle.writeFile(`${canonicalJson(expected)}\n`);
485
+ await handle.chmod(0o600);
486
+ await handle.sync();
487
+ await handle.close();
488
+ await fsyncDirectory(this.stateDir, this.stateDirIdentity);
489
+ await this.assertDirectories();
490
+ return;
491
+ }
492
+ catch (error) {
493
+ await handle?.close().catch(() => undefined);
494
+ if (!isErrno(error, "EEXIST")) {
495
+ throw storeError("Failed to create the A2A idempotency store manifest.", error);
496
+ }
497
+ }
498
+ let parsed;
499
+ let lastReadError;
500
+ for (let attempt = 0; attempt < 20; attempt += 1) {
501
+ try {
502
+ parsed = JSON.parse((await secureReadPrivateFile(path, /^manifest\.json$/u)).toString("utf8"));
503
+ lastReadError = undefined;
504
+ break;
505
+ }
506
+ catch (error) {
507
+ lastReadError = error;
508
+ await delayWithRef(10);
509
+ }
510
+ }
511
+ if (lastReadError !== undefined) {
512
+ throw storeError("A2A idempotency store manifest is malformed.", lastReadError);
513
+ }
514
+ if (!isRecord(parsed)
515
+ || parsed.schemaVersion !== expected.schemaVersion
516
+ || parsed.maxRecords !== expected.maxRecords
517
+ || parsed.providerScope !== expected.providerScope
518
+ || !hasOnlyKeys(parsed, ["maxRecords", "providerScope", "schemaVersion"])) {
519
+ throw storeError("A2A idempotency store manifest does not match namespace or maxRecords; migrate explicitly instead of reusing it.");
520
+ }
521
+ await this.assertDirectories();
522
+ }
523
+ async allocateSlot(keyHash) {
524
+ await this.assertDirectories();
525
+ // Reservations are permanent and allocated by deterministic linear probe.
526
+ // Therefore the same key is either encountered before the first free slot,
527
+ // or the first free slot is its one canonical reservation. No global slot
528
+ // scan is needed on each dispatch, even at large configured capacities.
529
+ const start = Number(BigInt(`0x${keyHash.slice(0, 13)}`) % BigInt(this.maxRecords));
530
+ for (let offset = 0; offset < this.maxRecords; offset += 1) {
531
+ const slot = (start + offset) % this.maxRecords;
532
+ const path = resolve(this.slotsDir, slotFileName(slot));
533
+ let handle;
534
+ try {
535
+ handle = await open(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
536
+ }
537
+ catch (error) {
538
+ if (isErrno(error, "EEXIST")) {
539
+ const reservation = await this.readSlot(slot);
540
+ if (reservation.keyHash === keyHash) {
541
+ // A concurrent same-key allocator may have made the fully written
542
+ // reservation visible before syncing its directory entry. Persist
543
+ // that entry in this process before the shared admission can run.
544
+ await fsyncDirectory(this.slotsDir, this.slotsDirIdentity);
545
+ await this.assertDirectories();
546
+ return slot;
547
+ }
548
+ continue;
549
+ }
550
+ throw storeError("Failed to reserve A2A idempotency capacity.", error);
551
+ }
552
+ try {
553
+ await handle.writeFile(`${canonicalJson({ schemaVersion: 1, slot, keyHash, createdAtMs: Date.now() })}\n`);
554
+ await handle.chmod(0o600);
555
+ await handle.sync();
556
+ }
557
+ catch (error) {
558
+ await handle.close().catch(() => undefined);
559
+ await unlink(path).catch(() => undefined);
560
+ throw storeError("Failed to persist A2A idempotency capacity reservation.", error);
561
+ }
562
+ await handle.close();
563
+ await fsyncDirectory(this.slotsDir, this.slotsDirIdentity);
564
+ await this.assertDirectories();
565
+ return slot;
566
+ }
567
+ throw protocolIdempotencyError("idempotency_capacity_exhausted", "The provider's durable idempotency admission capacity is exhausted; no active or conflict tombstone was evicted.");
568
+ }
569
+ async verifySlot(slot, keyHash) {
570
+ await this.assertDirectories();
571
+ if (!Number.isSafeInteger(slot) || slot < 0 || slot >= this.maxRecords) {
572
+ throw storeError("A2A idempotency record references an invalid capacity slot.");
573
+ }
574
+ const parsed = await this.readSlot(slot);
575
+ if (parsed.keyHash !== keyHash) {
576
+ throw storeError("A2A idempotency capacity reservation does not match its record.");
577
+ }
578
+ await this.assertDirectories();
579
+ }
580
+ async readSlot(slot) {
581
+ if (!Number.isSafeInteger(slot) || slot < 0 || slot >= this.maxRecords) {
582
+ throw storeError("A2A idempotency capacity reservation references an invalid slot.");
583
+ }
584
+ const path = resolve(this.slotsDir, slotFileName(slot));
585
+ let parsed;
586
+ let lastError;
587
+ for (let attempt = 0; attempt < 20; attempt += 1) {
588
+ try {
589
+ parsed = JSON.parse((await secureReadPrivateFile(path, SLOT_FILE_PATTERN)).toString("utf8"));
590
+ lastError = undefined;
591
+ break;
592
+ }
593
+ catch (error) {
594
+ lastError = error;
595
+ await delayWithRef(10);
596
+ }
597
+ }
598
+ if (lastError !== undefined) {
599
+ throw storeError("A2A idempotency capacity reservation is missing or malformed.", lastError);
600
+ }
601
+ if (!isRecord(parsed)
602
+ || parsed.schemaVersion !== 1
603
+ || parsed.slot !== slot
604
+ || typeof parsed.keyHash !== "string"
605
+ || !SHA256_PATTERN.test(parsed.keyHash)
606
+ || typeof parsed.createdAtMs !== "number"
607
+ || !Number.isSafeInteger(parsed.createdAtMs)
608
+ || parsed.createdAtMs < 0
609
+ || !hasOnlyKeys(parsed, ["createdAtMs", "keyHash", "schemaVersion", "slot"])) {
610
+ throw storeError("A2A idempotency capacity reservation does not match its record.");
611
+ }
612
+ return { keyHash: parsed.keyHash, createdAtMs: parsed.createdAtMs };
613
+ }
614
+ async compactExpiredRecord(record) {
615
+ const tombstone = {
616
+ schemaVersion: RECORD_SCHEMA_VERSION,
617
+ keyHash: record.keyHash,
618
+ fingerprint: record.fingerprint,
619
+ status: "tombstone",
620
+ createdAtMs: record.createdAtMs,
621
+ updatedAtMs: record.updatedAtMs,
622
+ tombstonedAtMs: Date.now(),
623
+ slot: record.slot,
624
+ ...(record.taskId === undefined ? {} : { taskId: record.taskId }),
625
+ };
626
+ // Keys and slots are never reused. Concurrent compactors can only replace
627
+ // the canonical record with the same semantic tombstone, so there is no
628
+ // successor admission for an expiry race to move or unlink.
629
+ await this.save(tombstone);
630
+ return tombstone;
631
+ }
632
+ async pruneExpiredTerminalRecords() {
633
+ await this.assertDirectories();
634
+ const entries = await readdir(this.stateDir, { withFileTypes: true });
635
+ for (const entry of entries) {
636
+ if (entry.name === STORE_MANIFEST_FILE || entry.name === SLOTS_DIRECTORY) {
637
+ continue;
638
+ }
639
+ if (!entry.isFile() || !RECORD_FILE_PATTERN.test(entry.name)) {
640
+ // Atomic-write remnants are retained for operator inspection; they do
641
+ // not count as admissions because capacity is held by slot files.
642
+ if (/^\.[a-f0-9]{64}\.[a-f0-9-]+\.tmp$/u.test(entry.name)) {
643
+ continue;
644
+ }
645
+ throw storeError(`Unexpected entry in A2A idempotency stateDir: ${entry.name}`);
646
+ }
647
+ const keyHash = entry.name.slice(0, -".json".length);
648
+ let record;
649
+ try {
650
+ record = parseRecord(JSON.parse((await secureReadPrivateFile(resolve(this.stateDir, entry.name), RECORD_FILE_PATTERN)).toString("utf8")), keyHash);
651
+ }
652
+ catch (error) {
653
+ throw storeError("A2A idempotency state is malformed during startup scan.", error);
654
+ }
655
+ await this.verifySlot(record.slot, keyHash);
656
+ if (record.status === "completed" && isExpired(record, this.retentionMs)) {
657
+ await this.compactExpiredRecord(record);
658
+ }
659
+ }
660
+ await this.assertDirectories();
661
+ }
662
+ async reconcileOrphanSlots() {
663
+ await this.assertDirectories();
664
+ const entries = await readdir(this.slotsDir, { withFileTypes: true });
665
+ const slotsByKey = new Map();
666
+ for (const entry of entries) {
667
+ const match = SLOT_FILE_PATTERN.exec(entry.name);
668
+ if (!entry.isFile() || match === null) {
669
+ if (/^\.slot-[0-9]{1,7}\.json\.[a-f0-9-]+\.released$/u.test(entry.name)) {
670
+ continue;
671
+ }
672
+ throw storeError(`Unexpected entry in A2A idempotency slots directory: ${entry.name}`);
673
+ }
674
+ const slot = Number(match[1]);
675
+ const parsed = await this.readSlot(slot);
676
+ const priorSlot = slotsByKey.get(parsed.keyHash);
677
+ if (priorSlot !== undefined && priorSlot !== slot) {
678
+ throw storeError("A2A idempotency key has multiple capacity reservations; migrate explicitly instead of guessing which admission owns the key.");
679
+ }
680
+ slotsByKey.set(parsed.keyHash, slot);
681
+ }
682
+ for (const [keyHash, slot] of slotsByKey) {
683
+ const record = await this.load(keyHash);
684
+ if (record !== undefined && record.slot !== slot) {
685
+ throw storeError("A2A idempotency capacity reservation does not match its record.");
686
+ }
687
+ }
688
+ await this.assertDirectories();
689
+ }
690
+ }
691
+ function readEnvelope(value) {
692
+ if (value === undefined) {
693
+ return undefined;
694
+ }
695
+ if (!isRecord(value)) {
696
+ throw protocolIdempotencyError("invalid_idempotency_key", "The A2A idempotency metadata envelope must be an object.");
697
+ }
698
+ const keys = Object.keys(value).sort();
699
+ if (keys.length !== 2
700
+ || keys[0] !== "key"
701
+ || keys[1] !== "schemaVersion"
702
+ || value.schemaVersion !== A2A_IDEMPOTENCY_SCHEMA_VERSION
703
+ || typeof value.key !== "string") {
704
+ throw protocolIdempotencyError("invalid_idempotency_key", "The A2A idempotency metadata envelope is invalid.");
705
+ }
706
+ try {
707
+ return a2aIdempotencyEnvelope(value.key);
708
+ }
709
+ catch {
710
+ throw protocolIdempotencyError("invalid_idempotency_key", "The A2A idempotency key format is invalid.");
711
+ }
712
+ }
713
+ function rejectUnsupportedIdempotencyEnvelope(params) {
714
+ const envelope = readEnvelope(params.metadata?.[A2A_IDEMPOTENCY_METADATA_KEY]);
715
+ if (envelope === undefined) {
716
+ return;
717
+ }
718
+ throw protocolIdempotencyError("idempotency_unsupported", "This A2A provider is not configured with durable logical-dispatch idempotency; refusing to ignore the reserved envelope.");
719
+ }
720
+ function requestFingerprint(params, tenant, providerScope) {
721
+ const message = params.message;
722
+ const requestMetadata = { ...(params.metadata ?? {}) };
723
+ delete requestMetadata[A2A_IDEMPOTENCY_METADATA_KEY];
724
+ return sha256(canonicalJson({
725
+ providerScope,
726
+ tenant,
727
+ message: message === undefined
728
+ ? undefined
729
+ : {
730
+ contextId: message.contextId,
731
+ taskId: message.taskId,
732
+ role: message.role,
733
+ parts: message.parts,
734
+ metadata: message.metadata,
735
+ extensions: message.extensions,
736
+ referenceTaskIds: message.referenceTaskIds,
737
+ },
738
+ configuration: executionConfiguration(params.configuration),
739
+ metadata: requestMetadata,
740
+ }));
741
+ }
742
+ function withoutIdempotencyEnvelope(params) {
743
+ const metadata = { ...(params.metadata ?? {}) };
744
+ delete metadata[A2A_IDEMPOTENCY_METADATA_KEY];
745
+ return {
746
+ ...params,
747
+ metadata,
748
+ };
749
+ }
750
+ function asImmediateExecutionRequest(params) {
751
+ const stripped = withoutIdempotencyEnvelope(params);
752
+ return {
753
+ ...stripped,
754
+ configuration: {
755
+ acceptedOutputModes: stripped.configuration?.acceptedOutputModes ?? ["text/plain"],
756
+ taskPushNotificationConfig: stripped.configuration?.taskPushNotificationConfig,
757
+ historyLength: undefined,
758
+ returnImmediately: true,
759
+ },
760
+ };
761
+ }
762
+ function executionConfiguration(configuration) {
763
+ return {
764
+ acceptedOutputModes: configuration?.acceptedOutputModes ?? ["text/plain"],
765
+ taskPushNotificationConfig: configuration?.taskPushNotificationConfig,
766
+ };
767
+ }
768
+ async function projectRuntimeResult(runtime, configuration) {
769
+ const result = configuration?.returnImmediately === true
770
+ ? await runtime.accepted
771
+ : await runtime.terminal;
772
+ return projectHistory(result, configuration?.historyLength);
773
+ }
774
+ function projectHistory(result, historyLength) {
775
+ const cloned = cloneResult(result);
776
+ if (!("status" in cloned) || historyLength === undefined) {
777
+ return cloned;
778
+ }
779
+ cloned.history = historyLength <= 0
780
+ ? []
781
+ : cloned.history.slice(-historyLength);
782
+ return cloned;
783
+ }
784
+ function assertSameFingerprint(expected, actual) {
785
+ if (expected === actual) {
786
+ return;
787
+ }
788
+ throw protocolIdempotencyError("idempotency_conflict", "The A2A idempotency key is already bound to a different canonical request.");
789
+ }
790
+ function protocolIdempotencyError(code, message) {
791
+ return new RequestMalformedError(`${ERROR_MARKER} ${code} ${message}`);
792
+ }
793
+ function normalizeRetentionMs(value) {
794
+ const retentionMs = value ?? DEFAULT_RETENTION_MS;
795
+ if (!Number.isSafeInteger(retentionMs) || retentionMs < MIN_RETENTION_MS || retentionMs > MAX_RETENTION_MS) {
796
+ throw new A2AProviderError("invalid_config", `A2A idempotency retentionMs must be an integer from ${MIN_RETENTION_MS} to ${MAX_RETENTION_MS}.`, { field: "idempotency.retentionMs" });
797
+ }
798
+ return retentionMs;
799
+ }
800
+ function normalizeMaxRecords(value) {
801
+ const maxRecords = value ?? DEFAULT_MAX_RECORDS;
802
+ if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || maxRecords > MAX_RECORDS_LIMIT) {
803
+ throw new A2AProviderError("invalid_config", `A2A idempotency maxRecords must be an integer from 1 to ${MAX_RECORDS_LIMIT}.`, { field: "idempotency.maxRecords" });
804
+ }
805
+ return maxRecords;
806
+ }
807
+ function idempotencyNamespaceHash(value) {
808
+ const namespace = typeof value === "string" ? value.trim() : "";
809
+ if (namespace.length === 0 || Buffer.byteLength(namespace, "utf8") > 512) {
810
+ throw new A2AProviderError("invalid_config", "A2A idempotency namespace must be non-empty and at most 512 UTF-8 bytes.", { field: "idempotency.namespace" });
811
+ }
812
+ return sha256(namespace);
813
+ }
814
+ function isExpired(record, retentionMs) {
815
+ return Date.now() - record.updatedAtMs > retentionMs;
816
+ }
817
+ function taskIdFromResult(result) {
818
+ const id = "status" in result ? result.id : result.taskId;
819
+ return id.trim().length === 0 ? undefined : id;
820
+ }
821
+ function isNonTerminalTask(result) {
822
+ return "status" in result && !isTerminalState(result.status?.state);
823
+ }
824
+ function isTerminalState(state) {
825
+ return state === TaskState.TASK_STATE_COMPLETED
826
+ || state === TaskState.TASK_STATE_FAILED
827
+ || state === TaskState.TASK_STATE_CANCELED
828
+ || state === TaskState.TASK_STATE_REJECTED
829
+ || state === TaskState.TASK_STATE_INPUT_REQUIRED
830
+ || state === TaskState.TASK_STATE_AUTH_REQUIRED;
831
+ }
832
+ function cloneResult(value) {
833
+ return revivePersistedResult(value);
834
+ }
835
+ function cloneRecord(value) {
836
+ return structuredClone(value);
837
+ }
838
+ function parseRecord(value, expectedKeyHash) {
839
+ if (!isRecord(value)) {
840
+ throw new Error("record must be an object");
841
+ }
842
+ const schemaVersion = value.schemaVersion;
843
+ const keyHash = value.keyHash;
844
+ const fingerprint = value.fingerprint;
845
+ const status = value.status;
846
+ const createdAtMs = value.createdAtMs;
847
+ const updatedAtMs = value.updatedAtMs;
848
+ const slot = value.slot;
849
+ if (schemaVersion !== RECORD_SCHEMA_VERSION
850
+ || keyHash !== expectedKeyHash
851
+ || typeof keyHash !== "string"
852
+ || !SHA256_PATTERN.test(keyHash)
853
+ || typeof fingerprint !== "string"
854
+ || !SHA256_PATTERN.test(fingerprint)
855
+ || (status !== "active" && status !== "completed" && status !== "tombstone")
856
+ || typeof createdAtMs !== "number"
857
+ || !Number.isSafeInteger(createdAtMs)
858
+ || createdAtMs < 0
859
+ || typeof updatedAtMs !== "number"
860
+ || !Number.isSafeInteger(updatedAtMs)
861
+ || updatedAtMs < 0
862
+ || typeof slot !== "number"
863
+ || !Number.isSafeInteger(slot)
864
+ || slot < 0
865
+ || (value.taskId !== undefined && (typeof value.taskId !== "string" || value.taskId.length === 0))) {
866
+ throw new Error("record fields are invalid");
867
+ }
868
+ if (status === "tombstone") {
869
+ if (!hasOnlyKeys(value, [
870
+ "createdAtMs", "fingerprint", "keyHash", "schemaVersion", "slot", "status",
871
+ "taskId", "tombstonedAtMs", "updatedAtMs",
872
+ ])
873
+ || typeof value.tombstonedAtMs !== "number"
874
+ || !Number.isSafeInteger(value.tombstonedAtMs)
875
+ || value.tombstonedAtMs < 0) {
876
+ throw new Error("tombstone record is invalid");
877
+ }
878
+ return {
879
+ schemaVersion,
880
+ keyHash,
881
+ fingerprint,
882
+ status,
883
+ createdAtMs,
884
+ updatedAtMs,
885
+ tombstonedAtMs: value.tombstonedAtMs,
886
+ slot,
887
+ ...(value.taskId === undefined ? {} : { taskId: value.taskId }),
888
+ };
889
+ }
890
+ if (status === "completed") {
891
+ if (!hasOnlyKeys(value, [
892
+ "createdAtMs", "fingerprint", "keyHash", "result", "schemaVersion",
893
+ "slot", "status", "taskId", "updatedAtMs",
894
+ ])
895
+ || !isSendMessageResult(value.result)
896
+ || !isTerminalPersistedResult(value.result)
897
+ || !recordTaskIdMatchesResult(value.taskId, value.result)) {
898
+ throw new Error("completed record result is invalid");
899
+ }
900
+ return {
901
+ schemaVersion,
902
+ keyHash,
903
+ fingerprint,
904
+ status,
905
+ createdAtMs,
906
+ updatedAtMs,
907
+ slot,
908
+ ...(value.taskId === undefined ? {} : { taskId: value.taskId }),
909
+ result: revivePersistedResult(value.result),
910
+ };
911
+ }
912
+ if (!hasOnlyKeys(value, [
913
+ "acceptedResult", "createdAtMs", "fingerprint", "keyHash", "schemaVersion",
914
+ "slot", "status", "taskId", "updatedAtMs",
915
+ ])
916
+ || (value.acceptedResult !== undefined && !isNonTerminalPersistedTask(value.acceptedResult))
917
+ || ((value.taskId === undefined) !== (value.acceptedResult === undefined))
918
+ || (value.acceptedResult !== undefined && !recordTaskIdMatchesResult(value.taskId, value.acceptedResult))) {
919
+ throw new Error("active record acceptedResult is invalid");
920
+ }
921
+ return {
922
+ schemaVersion,
923
+ keyHash,
924
+ fingerprint,
925
+ status,
926
+ createdAtMs,
927
+ updatedAtMs,
928
+ slot,
929
+ ...(value.taskId === undefined ? {} : { taskId: value.taskId }),
930
+ ...(value.acceptedResult === undefined
931
+ ? {}
932
+ : { acceptedResult: revivePersistedResult(value.acceptedResult) }),
933
+ };
934
+ }
935
+ function isSendMessageResult(value) {
936
+ if (!isRecord(value)) {
937
+ return false;
938
+ }
939
+ return typeof value.messageId === "string"
940
+ ? isPersistedMessage(value)
941
+ : isPersistedTask(value);
942
+ }
943
+ function isTerminalPersistedResult(value) {
944
+ return !("status" in value) || isTerminalState(value.status?.state);
945
+ }
946
+ function isNonTerminalPersistedTask(value) {
947
+ if (!isRecord(value) || !isPersistedTask(value)) {
948
+ return false;
949
+ }
950
+ return !isTerminalState(value.status?.state);
951
+ }
952
+ function recordTaskIdMatchesResult(recordTaskId, result) {
953
+ const resultTaskId = "status" in result ? result.id : result.taskId;
954
+ const normalizedResultTaskId = resultTaskId.length === 0 ? undefined : resultTaskId;
955
+ return recordTaskId === normalizedResultTaskId;
956
+ }
957
+ function isPersistedTask(value) {
958
+ return hasOnlyKeys(value, ["artifacts", "contextId", "history", "id", "metadata", "status"])
959
+ && typeof value.id === "string"
960
+ && value.id.length > 0
961
+ && typeof value.contextId === "string"
962
+ && Array.isArray(value.artifacts)
963
+ && value.artifacts.every(isPersistedArtifact)
964
+ && Array.isArray(value.history)
965
+ && value.history.every((message) => isRecord(message) && isPersistedMessage(message))
966
+ && (value.metadata === undefined || isRecord(value.metadata))
967
+ && isPersistedStatus(value.status);
968
+ }
969
+ function isPersistedStatus(value) {
970
+ return isRecord(value)
971
+ && hasOnlyKeys(value, ["message", "state", "timestamp"])
972
+ && typeof value.state === "number"
973
+ && Number.isInteger(value.state)
974
+ && value.state >= 0
975
+ && value.state <= 8
976
+ && (value.message === undefined || (isRecord(value.message) && isPersistedMessage(value.message)))
977
+ && (value.timestamp === undefined || typeof value.timestamp === "string");
978
+ }
979
+ function isPersistedMessage(value) {
980
+ return hasOnlyKeys(value, [
981
+ "contextId",
982
+ "extensions",
983
+ "messageId",
984
+ "metadata",
985
+ "parts",
986
+ "referenceTaskIds",
987
+ "role",
988
+ "taskId",
989
+ ])
990
+ && typeof value.messageId === "string"
991
+ && value.messageId.length > 0
992
+ && typeof value.contextId === "string"
993
+ && typeof value.taskId === "string"
994
+ && (value.role === 1 || value.role === 2)
995
+ && Array.isArray(value.parts)
996
+ && value.parts.every(isPersistedPart)
997
+ && (value.metadata === undefined || isRecord(value.metadata))
998
+ && isStringArray(value.extensions)
999
+ && isStringArray(value.referenceTaskIds);
1000
+ }
1001
+ function isPersistedPart(value) {
1002
+ if (!isRecord(value)
1003
+ || !hasOnlyKeys(value, ["content", "filename", "mediaType", "metadata"])
1004
+ || typeof value.filename !== "string"
1005
+ || typeof value.mediaType !== "string"
1006
+ || (value.metadata !== undefined && !isRecord(value.metadata))) {
1007
+ return false;
1008
+ }
1009
+ if (value.content === undefined) {
1010
+ return true;
1011
+ }
1012
+ if (!isRecord(value.content) || !hasOnlyKeys(value.content, ["$case", "value"])) {
1013
+ return false;
1014
+ }
1015
+ if (value.content.$case === "text" || value.content.$case === "url") {
1016
+ return typeof value.content.value === "string";
1017
+ }
1018
+ if (value.content.$case === "raw") {
1019
+ return typeof value.content.value === "string" && isCanonicalBase64(value.content.value);
1020
+ }
1021
+ return value.content.$case === "data"
1022
+ && (value.content.value === undefined || isJsonValue(value.content.value));
1023
+ }
1024
+ function isPersistedArtifact(value) {
1025
+ return isRecord(value)
1026
+ && hasOnlyKeys(value, ["artifactId", "description", "extensions", "metadata", "name", "parts"])
1027
+ && typeof value.artifactId === "string"
1028
+ && value.artifactId.length > 0
1029
+ && typeof value.name === "string"
1030
+ && typeof value.description === "string"
1031
+ && Array.isArray(value.parts)
1032
+ && value.parts.every(isPersistedPart)
1033
+ && (value.metadata === undefined || isRecord(value.metadata))
1034
+ && isStringArray(value.extensions);
1035
+ }
1036
+ function isStringArray(value) {
1037
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
1038
+ }
1039
+ function isJsonValue(value) {
1040
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
1041
+ return true;
1042
+ }
1043
+ if (typeof value === "number") {
1044
+ return Number.isFinite(value);
1045
+ }
1046
+ if (Array.isArray(value)) {
1047
+ return value.every(isJsonValue);
1048
+ }
1049
+ return isRecord(value) && Object.values(value).every(isJsonValue);
1050
+ }
1051
+ function isCanonicalBase64(value) {
1052
+ try {
1053
+ return Buffer.from(value, "base64").toString("base64") === value;
1054
+ }
1055
+ catch {
1056
+ return false;
1057
+ }
1058
+ }
1059
+ function revivePersistedResult(value) {
1060
+ const result = structuredClone(value);
1061
+ if ("status" in result) {
1062
+ for (const artifact of result.artifacts) {
1063
+ revivePersistedParts(artifact.parts);
1064
+ }
1065
+ for (const message of result.history) {
1066
+ revivePersistedParts(message.parts);
1067
+ }
1068
+ if (result.status?.message !== undefined) {
1069
+ revivePersistedParts(result.status.message.parts);
1070
+ }
1071
+ }
1072
+ else {
1073
+ revivePersistedParts(result.parts);
1074
+ }
1075
+ return result;
1076
+ }
1077
+ function revivePersistedParts(parts) {
1078
+ for (const part of parts) {
1079
+ const content = part.content;
1080
+ if (content?.$case !== "raw") {
1081
+ continue;
1082
+ }
1083
+ if (typeof content.value === "string") {
1084
+ content.value = Buffer.from(content.value, "base64");
1085
+ }
1086
+ else if (!Buffer.isBuffer(content.value)) {
1087
+ // Node's structuredClone preserves the bytes but returns Uint8Array,
1088
+ // while the A2A SDK contract requires raw Part values to remain Buffer.
1089
+ content.value = Buffer.from(content.value);
1090
+ }
1091
+ }
1092
+ }
1093
+ function hasOnlyKeys(value, allowed) {
1094
+ const allowedSet = new Set(allowed);
1095
+ return Object.keys(value).every((key) => allowedSet.has(key));
1096
+ }
1097
+ function canonicalJson(value) {
1098
+ return JSON.stringify(canonicalValue(value));
1099
+ }
1100
+ function canonicalValue(value) {
1101
+ if (value instanceof Uint8Array) {
1102
+ return Buffer.from(value).toString("base64");
1103
+ }
1104
+ if (Array.isArray(value)) {
1105
+ return value.map(canonicalValue);
1106
+ }
1107
+ if (isRecord(value)) {
1108
+ return Object.fromEntries(Object.entries(value)
1109
+ .filter(([, child]) => child !== undefined)
1110
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
1111
+ .map(([key, child]) => [key, canonicalValue(child)]));
1112
+ }
1113
+ return value;
1114
+ }
1115
+ function sha256(value) {
1116
+ return createHash("sha256").update(value).digest("hex");
1117
+ }
1118
+ function slotFileName(slot) {
1119
+ return `slot-${slot}.json`;
1120
+ }
1121
+ async function ensurePrivateStateDir(path) {
1122
+ const absolute = resolve(path);
1123
+ try {
1124
+ const canonical = await realpath(absolute);
1125
+ const identity = await inspectPrivateDirectory(absolute, canonical);
1126
+ await fsyncDirectory(canonical, identity);
1127
+ return { path: canonical, identity };
1128
+ }
1129
+ catch (error) {
1130
+ if (!isErrno(error, "ENOENT")) {
1131
+ throw error instanceof A2AProviderError
1132
+ ? error
1133
+ : storeError("A2A idempotency stateDir must be an owner-only 0700 real directory.", error);
1134
+ }
1135
+ }
1136
+ const missing = [];
1137
+ let existing = absolute;
1138
+ while (true) {
1139
+ try {
1140
+ await lstat(existing);
1141
+ break;
1142
+ }
1143
+ catch (error) {
1144
+ if (!isErrno(error, "ENOENT")) {
1145
+ throw storeError("Failed to inspect the A2A idempotency directory chain.", error);
1146
+ }
1147
+ const parent = dirname(existing);
1148
+ if (parent === existing) {
1149
+ throw storeError("A2A idempotency stateDir has no existing directory ancestor.");
1150
+ }
1151
+ missing.unshift(basename(existing));
1152
+ existing = parent;
1153
+ }
1154
+ }
1155
+ let current;
1156
+ try {
1157
+ current = await realpath(existing);
1158
+ await inspectDirectory(current);
1159
+ }
1160
+ catch (error) {
1161
+ throw storeError("A2A idempotency stateDir ancestor must resolve to a real directory.", error);
1162
+ }
1163
+ for (const name of missing) {
1164
+ const parentIdentity = await inspectDirectory(current);
1165
+ const next = resolve(current, name);
1166
+ try {
1167
+ await mkdir(next, { mode: 0o700 });
1168
+ }
1169
+ catch (error) {
1170
+ if (!isErrno(error, "EEXIST")) {
1171
+ throw storeError("Failed to create the A2A idempotency directory chain.", error);
1172
+ }
1173
+ }
1174
+ let nextIdentity;
1175
+ try {
1176
+ nextIdentity = await inspectPrivateDirectory(next, next);
1177
+ // Persist both the new directory contents/inode and the directory entry
1178
+ // that links it from its parent before creating the next component.
1179
+ await fsyncDirectory(next, nextIdentity);
1180
+ await fsyncDirectory(current, parentIdentity);
1181
+ await assertDirectoryIdentity(current, parentIdentity);
1182
+ }
1183
+ catch (error) {
1184
+ throw storeError("A2A idempotency directory creation was replaced or is not owner-only.", error);
1185
+ }
1186
+ current = next;
1187
+ }
1188
+ const identity = await inspectPrivateDirectory(current, current);
1189
+ await fsyncDirectory(current, identity);
1190
+ return { path: current, identity };
1191
+ }
1192
+ async function inspectPrivateDirectory(path, canonicalPath) {
1193
+ const identity = await inspectDirectory(path);
1194
+ const canonicalIdentity = path === canonicalPath
1195
+ ? identity
1196
+ : await inspectDirectory(canonicalPath);
1197
+ const details = await lstat(path);
1198
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
1199
+ if (details.isSymbolicLink()
1200
+ || identity.dev !== canonicalIdentity.dev
1201
+ || identity.ino !== canonicalIdentity.ino
1202
+ || (details.mode & 0o777) !== 0o700
1203
+ || (uid !== undefined && details.uid !== uid)) {
1204
+ throw storeError("A2A idempotency stateDir must be an owner-only 0700 real directory.");
1205
+ }
1206
+ return identity;
1207
+ }
1208
+ async function inspectDirectory(path) {
1209
+ const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | (fsConstants.O_NOFOLLOW ?? 0));
1210
+ try {
1211
+ const opened = await handle.stat();
1212
+ const named = await lstat(path);
1213
+ if (!opened.isDirectory()
1214
+ || !named.isDirectory()
1215
+ || named.isSymbolicLink()
1216
+ || opened.dev !== named.dev
1217
+ || opened.ino !== named.ino) {
1218
+ throw new Error("directory identity is unsafe");
1219
+ }
1220
+ return { dev: opened.dev, ino: opened.ino };
1221
+ }
1222
+ finally {
1223
+ await handle.close();
1224
+ }
1225
+ }
1226
+ async function assertPrivateDirectoryIdentity(path, expected) {
1227
+ const actual = await inspectPrivateDirectory(path, path);
1228
+ if (actual.dev !== expected.dev || actual.ino !== expected.ino) {
1229
+ throw new Error("directory identity changed");
1230
+ }
1231
+ }
1232
+ async function assertDirectoryIdentity(path, expected) {
1233
+ const actual = await inspectDirectory(path);
1234
+ if (actual.dev !== expected.dev || actual.ino !== expected.ino) {
1235
+ throw new Error("directory identity changed");
1236
+ }
1237
+ }
1238
+ async function secureReadPrivateFile(path, allowedName) {
1239
+ allowedName.lastIndex = 0;
1240
+ if (!allowedName.test(basename(path))) {
1241
+ throw new Error("invalid record path");
1242
+ }
1243
+ const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
1244
+ try {
1245
+ const details = await handle.stat();
1246
+ const pathDetails = await stat(path);
1247
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
1248
+ if (!details.isFile()
1249
+ || details.nlink !== 1
1250
+ || (details.mode & 0o777) !== 0o600
1251
+ || details.dev !== pathDetails.dev
1252
+ || details.ino !== pathDetails.ino
1253
+ || details.size > MAX_RECORD_BYTES
1254
+ || (uid !== undefined && details.uid !== uid)) {
1255
+ throw new Error("record identity or permissions are unsafe");
1256
+ }
1257
+ return await readFile(handle);
1258
+ }
1259
+ finally {
1260
+ await handle.close();
1261
+ }
1262
+ }
1263
+ async function assertReplaceableRecord(path) {
1264
+ try {
1265
+ const details = await lstat(path);
1266
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
1267
+ if (!details.isFile()
1268
+ || details.isSymbolicLink()
1269
+ || details.nlink !== 1
1270
+ || (details.mode & 0o777) !== 0o600
1271
+ || (uid !== undefined && details.uid !== uid)) {
1272
+ throw new Error("existing record is unsafe");
1273
+ }
1274
+ }
1275
+ catch (error) {
1276
+ if (!isErrno(error, "ENOENT")) {
1277
+ throw error;
1278
+ }
1279
+ }
1280
+ }
1281
+ async function fsyncDirectory(path, expected) {
1282
+ const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | (fsConstants.O_NOFOLLOW ?? 0));
1283
+ try {
1284
+ if (expected !== undefined) {
1285
+ const details = await handle.stat();
1286
+ if (!details.isDirectory()
1287
+ || details.dev !== expected.dev
1288
+ || details.ino !== expected.ino) {
1289
+ throw new Error("directory identity changed before fsync");
1290
+ }
1291
+ }
1292
+ await handle.sync();
1293
+ }
1294
+ finally {
1295
+ await handle.close();
1296
+ }
1297
+ }
1298
+ function assertKeyHash(keyHash) {
1299
+ if (!SHA256_PATTERN.test(keyHash)) {
1300
+ throw storeError("A2A idempotency record key hash is invalid.");
1301
+ }
1302
+ }
1303
+ function storeError(message, cause) {
1304
+ return new A2AProviderError("idempotency_store_error", message, cause === undefined
1305
+ ? {}
1306
+ : { reason: cause instanceof Error ? cause.message : String(cause) });
1307
+ }
1308
+ function isRecord(value) {
1309
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1310
+ }
1311
+ function isErrno(error, code) {
1312
+ return error instanceof Error && "code" in error && error.code === code;
1313
+ }
1314
+ function unrefDelay(ms) {
1315
+ return new Promise((resolveDelay) => {
1316
+ const timer = setTimeout(resolveDelay, ms);
1317
+ timer.unref();
1318
+ });
1319
+ }
1320
+ function delayWithRef(ms) {
1321
+ return new Promise((resolveDelay) => {
1322
+ setTimeout(resolveDelay, ms);
1323
+ });
1324
+ }
1325
+ //# sourceMappingURL=idempotency.js.map