@adcp/sdk 14.0.0-beta.17 → 14.0.0-beta.19
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.
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/decisioning/context.d.mts +4 -0
- package/dist/lib/server/decisioning/context.d.ts +4 -0
- package/dist/lib/server/decisioning/index.d.mts +1 -0
- package/dist/lib/server/decisioning/index.d.ts +1 -0
- package/dist/lib/server/decisioning/index.js +11 -0
- package/dist/lib/server/decisioning/index.mjs +12 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.js +5 -3
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.mjs +5 -3
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.mts +136 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.ts +136 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.js +745 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.mjs +717 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.js +108 -36
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.mjs +108 -36
- package/dist/lib/server/decisioning/runtime/to-context.js +38 -9
- package/dist/lib/server/decisioning/runtime/to-context.mjs +38 -9
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- package/dist/lib/types/schemas.generated.js +0 -63
- package/dist/lib/types/schemas.generated.mjs +0 -63
- package/dist/lib/utils/well-formed-unicode.d.mts +2 -0
- package/dist/lib/utils/well-formed-unicode.d.ts +2 -0
- package/dist/lib/utils/well-formed-unicode.js +51 -0
- package/dist/lib/utils/well-formed-unicode.mjs +27 -0
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.mjs +3 -3
- package/docs/guides/BUILD-AN-AGENT.md +7 -0
- package/docs/guides/DURABLE-TASK-SETTLEMENT.md +468 -0
- package/docs/llms.txt +5 -2
- package/docs/migration-13-to-14.md +1 -1
- package/docs/migration-task-registry-scoping.md +45 -14
- package/package.json +2 -1
|
@@ -27,6 +27,7 @@ module.exports = __toCommonJS(postgres_task_settlement_exports);
|
|
|
27
27
|
var import_node_crypto = require("node:crypto");
|
|
28
28
|
var import_jcs = require('../../../utils/jcs.js');
|
|
29
29
|
var import_adcp_version_config = require('../../../utils/adcp-version-config.js');
|
|
30
|
+
var import_well_formed_unicode = require('../../../utils/well-formed-unicode.js');
|
|
30
31
|
var import_errors = require('../../errors.js');
|
|
31
32
|
var import_common = require('../../webhook-delivery/common.js');
|
|
32
33
|
var import_recovery = require('../../webhook-delivery/recovery.js');
|
|
@@ -51,17 +52,20 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
51
52
|
"Crash-safe task settlement requires a pg Pool with connect(); a query-only adapter cannot own a transaction"
|
|
52
53
|
);
|
|
53
54
|
}
|
|
54
|
-
|
|
55
|
+
const registryId = options.registry.registryId;
|
|
56
|
+
if (typeof registryId !== "string" || registryId.length === 0) {
|
|
55
57
|
throw new TypeError("Crash-safe task settlement requires task registry storageId/registryId binding");
|
|
56
58
|
}
|
|
57
59
|
if (typeof options.publisherScope !== "string" || options.publisherScope.length === 0) {
|
|
58
60
|
throw new TypeError("publisherScope must be a non-empty string");
|
|
59
61
|
}
|
|
62
|
+
(0, import_well_formed_unicode.assertWellFormedUnicode)(registryId, "Task settlement registryId");
|
|
63
|
+
(0, import_well_formed_unicode.assertWellFormedUnicode)(options.publisherScope, "Task settlement publisherScope");
|
|
60
64
|
const outboxOptions = options.outbox ?? {};
|
|
61
65
|
const outboxTable = (0, import_common.quoteWebhookTable)(outboxOptions.tableName ?? "adcp_webhook_outbox");
|
|
62
66
|
const claimScope = {
|
|
63
67
|
publisherScope: options.publisherScope,
|
|
64
|
-
tenantScope: stableRegistryScope(
|
|
68
|
+
tenantScope: stableRegistryScope(registryId, binding.namespace)
|
|
65
69
|
};
|
|
66
70
|
const backend = (0, import_pg.pgWebhookDeliveryRecoveryBackend)(binding.pool, { ...outboxOptions, claimScope });
|
|
67
71
|
const recovery = (0, import_recovery.createWebhookDeliveryRecovery)({
|
|
@@ -74,22 +78,35 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
74
78
|
durability: "durable",
|
|
75
79
|
recovery,
|
|
76
80
|
async settle(ref, terminal, push) {
|
|
77
|
-
|
|
81
|
+
const settlementRef = snapshotSettlementRef(ref);
|
|
82
|
+
if (settlementRef.registryId !== registryId) {
|
|
78
83
|
return { outcome: "not_found_in_scope", delivery: "not_applicable" };
|
|
79
84
|
}
|
|
85
|
+
let clonedTerminal;
|
|
86
|
+
let clonedPush;
|
|
87
|
+
try {
|
|
88
|
+
clonedTerminal = structuredClone(terminal);
|
|
89
|
+
clonedPush = structuredClone(push);
|
|
90
|
+
(0, import_well_formed_unicode.assertWellFormedUnicode)(clonedTerminal, "Task terminal result/error");
|
|
91
|
+
(0, import_well_formed_unicode.assertWellFormedUnicode)(clonedPush, "Task push settlement config");
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
throw new TaskPushSettlementConfigurationError("Task settlement inputs must contain serializable JSON", {
|
|
94
|
+
cause
|
|
95
|
+
});
|
|
96
|
+
}
|
|
80
97
|
const deliveryKey = {
|
|
81
98
|
...claimScope,
|
|
82
|
-
deliveryId: stableScope("task-webhook",
|
|
99
|
+
deliveryId: stableScope("task-webhook", settlementRef)
|
|
83
100
|
};
|
|
84
101
|
const transactionalPool = pool;
|
|
85
102
|
let observed;
|
|
86
103
|
try {
|
|
87
|
-
observed = await readTaskRow(transactionalPool, binding.tableName, binding.namespace,
|
|
104
|
+
observed = await readTaskRow(transactionalPool, binding.tableName, binding.namespace, settlementRef, false);
|
|
88
105
|
} catch (cause) {
|
|
89
106
|
throwSettlementFailure(cause);
|
|
90
107
|
}
|
|
91
108
|
if (!observed) return { outcome: "not_found_in_scope", delivery: "not_applicable" };
|
|
92
|
-
validatePush(
|
|
109
|
+
validatePush(clonedPush);
|
|
93
110
|
if (observed.has_webhook !== true) {
|
|
94
111
|
throw new TaskPushSettlementConfigurationError(
|
|
95
112
|
"Crash-safe push settlement requires a task created with hasWebhook: true"
|
|
@@ -98,8 +115,9 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
98
115
|
let result;
|
|
99
116
|
let error;
|
|
100
117
|
try {
|
|
101
|
-
result =
|
|
102
|
-
error =
|
|
118
|
+
result = clonedTerminal.status === "completed" ? (0, import_task_registry.sanitizeTaskResultForWire)(clonedTerminal.result, settlementRef) : clonedTerminal.result === void 0 ? { errors: [(0, import_errors.sanitizeStructuredAdcpError)(clonedTerminal.error)] } : (0, import_task_registry.sanitizeTaskResultForWire)(clonedTerminal.result, settlementRef);
|
|
119
|
+
error = clonedTerminal.status === "failed" ? (0, import_errors.sanitizeStructuredAdcpError)(clonedTerminal.error) : void 0;
|
|
120
|
+
(0, import_well_formed_unicode.assertWellFormedUnicode)({ result, error }, "Task terminal result/error");
|
|
103
121
|
(0, import_jcs.canonicalJsonSha256)({ result, error });
|
|
104
122
|
assertPayloadSize(result, error);
|
|
105
123
|
} catch (cause) {
|
|
@@ -108,7 +126,7 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
108
126
|
cause
|
|
109
127
|
});
|
|
110
128
|
}
|
|
111
|
-
if (TERMINAL.has(observed.status) && !terminalMatches(observed,
|
|
129
|
+
if (TERMINAL.has(observed.status) && !terminalMatches(observed, clonedTerminal.status, result, error, settlementRef)) {
|
|
112
130
|
return {
|
|
113
131
|
outcome: "already_terminal",
|
|
114
132
|
status: observed.status,
|
|
@@ -135,27 +153,27 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
135
153
|
const existingTimestamp = observedOutbox?.state === "pending" ? observedOutbox.snapshot.payload.timestamp : void 0;
|
|
136
154
|
const payload = {
|
|
137
155
|
idempotency_key: `pending.${deliveryKey.deliveryId.slice(-48)}`,
|
|
138
|
-
operation_id: resolveOperationId(
|
|
139
|
-
task_id:
|
|
156
|
+
operation_id: resolveOperationId(clonedPush, observed.tool, settlementRef.taskId),
|
|
157
|
+
task_id: settlementRef.taskId,
|
|
140
158
|
task_type: observed.tool,
|
|
141
|
-
status:
|
|
159
|
+
status: clonedTerminal.status,
|
|
142
160
|
timestamp: typeof existingTimestamp === "string" ? existingTimestamp : (/* @__PURE__ */ new Date()).toISOString(),
|
|
143
161
|
protocol: (0, import_protocol_for_tool.protocolForTool)(observed.tool),
|
|
144
162
|
result,
|
|
145
|
-
...
|
|
146
|
-
...
|
|
163
|
+
...clonedPush.token !== void 0 && { token: clonedPush.token },
|
|
164
|
+
...clonedTerminal.status === "failed" && { message: error.message }
|
|
147
165
|
};
|
|
148
166
|
let prepared;
|
|
149
167
|
try {
|
|
150
168
|
prepared = await recovery.prepare(
|
|
151
169
|
deliveryKey,
|
|
152
170
|
{
|
|
153
|
-
url:
|
|
171
|
+
url: clonedPush.url,
|
|
154
172
|
payload,
|
|
155
|
-
authentication:
|
|
173
|
+
authentication: clonedPush.authentication ?? null,
|
|
156
174
|
retries
|
|
157
175
|
},
|
|
158
|
-
{ protectPayloadToken:
|
|
176
|
+
{ protectPayloadToken: clonedPush.token !== void 0 }
|
|
159
177
|
);
|
|
160
178
|
} catch (cause) {
|
|
161
179
|
if (cause instanceof import_recovery.WebhookAuthenticationProtectionError) throwSettlementFailure(cause);
|
|
@@ -163,12 +181,12 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
163
181
|
cause
|
|
164
182
|
});
|
|
165
183
|
}
|
|
166
|
-
const intentFingerprint = taskPushIntentFingerprint(prepared.snapshot);
|
|
184
|
+
const intentFingerprint = taskPushIntentFingerprint(prepared.snapshot, settlementRef);
|
|
167
185
|
let client;
|
|
168
186
|
try {
|
|
169
187
|
client = await transactionalPool.connect();
|
|
170
188
|
await client.query("BEGIN");
|
|
171
|
-
const row = await readTaskRow(client, binding.tableName, binding.namespace,
|
|
189
|
+
const row = await readTaskRow(client, binding.tableName, binding.namespace, settlementRef, true);
|
|
172
190
|
if (!row) {
|
|
173
191
|
await client.query("ROLLBACK");
|
|
174
192
|
return { outcome: "not_found_in_scope", delivery: "not_applicable" };
|
|
@@ -186,7 +204,7 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
186
204
|
`Task type ${row.tool} cannot be emitted by the closed AdCP task-webhook schema`
|
|
187
205
|
);
|
|
188
206
|
}
|
|
189
|
-
const compatible = terminalMatches(row,
|
|
207
|
+
const compatible = terminalMatches(row, clonedTerminal.status, result, error, settlementRef);
|
|
190
208
|
if (TERMINAL.has(row.status) && !compatible) {
|
|
191
209
|
await client.query("COMMIT");
|
|
192
210
|
return {
|
|
@@ -208,7 +226,9 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
208
226
|
if (!TERMINAL.has(row.status)) {
|
|
209
227
|
throw new Error("Settled webhook outbox row exists for a non-terminal task");
|
|
210
228
|
}
|
|
211
|
-
|
|
229
|
+
const matchesCurrentIntent = existing.intent_fingerprint === intentFingerprint;
|
|
230
|
+
const matchesLegacyArtifact = existing.intent_fingerprint !== null && existing.intent_fingerprint === taskPushLegacyIntentFingerprint(prepared.snapshot, row.result);
|
|
231
|
+
if (existing.intent_fingerprint !== null && !matchesCurrentIntent && !matchesLegacyArtifact) {
|
|
212
232
|
throw new TaskPushSettlementConfigurationError(
|
|
213
233
|
"Terminal webhook delivery identity is already bound to a conflicting route or payload"
|
|
214
234
|
);
|
|
@@ -221,7 +241,7 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
221
241
|
delivery: deliveryState(existing)
|
|
222
242
|
};
|
|
223
243
|
}
|
|
224
|
-
if (existing && !outboxMatchesPrepared(existing,
|
|
244
|
+
if (existing && !outboxMatchesPrepared(existing, intentFingerprint, settlementRef)) {
|
|
225
245
|
throw new TaskPushSettlementConfigurationError(
|
|
226
246
|
"Terminal webhook delivery identity is already bound to a conflicting route or payload"
|
|
227
247
|
);
|
|
@@ -245,7 +265,7 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
245
265
|
);
|
|
246
266
|
if ((write.rowCount ?? 0) !== 1) {
|
|
247
267
|
const raced = await readOutbox(client, outboxTable, deliveryKey, true);
|
|
248
|
-
if (!raced || !outboxMatchesPrepared(raced,
|
|
268
|
+
if (!raced || !outboxMatchesPrepared(raced, intentFingerprint, settlementRef)) {
|
|
249
269
|
throw new TaskPushSettlementConfigurationError(
|
|
250
270
|
"Terminal webhook delivery identity is already bound to a conflicting route or payload"
|
|
251
271
|
);
|
|
@@ -257,22 +277,28 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
257
277
|
}
|
|
258
278
|
}
|
|
259
279
|
if (!TERMINAL.has(row.status)) {
|
|
260
|
-
const update =
|
|
280
|
+
const update = clonedTerminal.status === "completed" ? await client.query(
|
|
261
281
|
`UPDATE ${binding.tableName}
|
|
262
282
|
SET status = 'completed', result = $5::jsonb, error = NULL,
|
|
263
283
|
status_message = NULL, updated_at = clock_timestamp()
|
|
264
284
|
WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,
|
|
265
|
-
[
|
|
285
|
+
[
|
|
286
|
+
settlementRef.taskId,
|
|
287
|
+
binding.namespace,
|
|
288
|
+
settlementRef.accountId,
|
|
289
|
+
settlementRef.ownerScope,
|
|
290
|
+
JSON.stringify(result)
|
|
291
|
+
]
|
|
266
292
|
) : await client.query(
|
|
267
293
|
`UPDATE ${binding.tableName}
|
|
268
294
|
SET status = 'failed', result = $5::jsonb, error = $6::jsonb,
|
|
269
295
|
status_message = $7, updated_at = clock_timestamp()
|
|
270
296
|
WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,
|
|
271
297
|
[
|
|
272
|
-
|
|
298
|
+
settlementRef.taskId,
|
|
273
299
|
binding.namespace,
|
|
274
|
-
|
|
275
|
-
|
|
300
|
+
settlementRef.accountId,
|
|
301
|
+
settlementRef.ownerScope,
|
|
276
302
|
JSON.stringify(result),
|
|
277
303
|
JSON.stringify(error),
|
|
278
304
|
error.message
|
|
@@ -304,9 +330,33 @@ async function completeScopedPushTask(coordinator, ref, push, result) {
|
|
|
304
330
|
async function failScopedPushTask(coordinator, ref, push, error, result) {
|
|
305
331
|
return coordinator.settle(ref, { status: "failed", error, ...result !== void 0 && { result } }, push);
|
|
306
332
|
}
|
|
307
|
-
function
|
|
333
|
+
function snapshotSettlementRef(ref) {
|
|
334
|
+
const cloned = structuredClone(ref);
|
|
335
|
+
const snapshot = {
|
|
336
|
+
registryId: requireSettlementRefPart(cloned.registryId, "registryId"),
|
|
337
|
+
accountId: requireSettlementRefPart(cloned.accountId, "accountId"),
|
|
338
|
+
ownerScope: requireSettlementRefPart(cloned.ownerScope, "ownerScope"),
|
|
339
|
+
taskId: requireSettlementRefPart(cloned.taskId, "taskId")
|
|
340
|
+
};
|
|
341
|
+
(0, import_well_formed_unicode.assertWellFormedUnicode)(snapshot, "Task settlement reference");
|
|
342
|
+
return snapshot;
|
|
343
|
+
}
|
|
344
|
+
function requireSettlementRefPart(value, field) {
|
|
345
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
346
|
+
throw new TypeError(`Task settlement ${field} must be a non-empty string`);
|
|
347
|
+
}
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
function terminalMatches(row, status, result, error, ref) {
|
|
308
351
|
if (row.status !== status) return false;
|
|
309
|
-
|
|
352
|
+
try {
|
|
353
|
+
(0, import_well_formed_unicode.assertWellFormedUnicode)({ result: row.result, error: row.error }, "Stored task terminal result/error");
|
|
354
|
+
const storedResult = (0, import_task_registry.sanitizeTaskResultForWire)(structuredClone(row.result), ref);
|
|
355
|
+
const storedError = status === "failed" ? (0, import_errors.sanitizeStructuredAdcpError)(structuredClone(row.error)) : void 0;
|
|
356
|
+
return (0, import_jcs.canonicalJsonSha256)(storedResult) === (0, import_jcs.canonicalJsonSha256)(result) && (status === "completed" || (0, import_jcs.canonicalJsonSha256)(storedError) === (0, import_jcs.canonicalJsonSha256)(error));
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
310
360
|
}
|
|
311
361
|
function stableScope(prefix, ref) {
|
|
312
362
|
const digest = (0, import_node_crypto.createHash)("sha256").update(JSON.stringify([ref.registryId, ref.accountId, ref.ownerScope, ref.taskId])).digest("hex");
|
|
@@ -316,20 +366,36 @@ function stableRegistryScope(registryId, namespace) {
|
|
|
316
366
|
const digest = (0, import_node_crypto.createHash)("sha256").update(JSON.stringify([registryId, namespace])).digest("hex");
|
|
317
367
|
return `task-registry:${digest}`;
|
|
318
368
|
}
|
|
319
|
-
function taskPushIntentFingerprint(snapshot) {
|
|
320
|
-
const { timestamp: _generatedTimestamp, ...
|
|
321
|
-
|
|
369
|
+
function taskPushIntentFingerprint(snapshot, ref) {
|
|
370
|
+
const { timestamp: _generatedTimestamp, ...storedPayload } = snapshot.payload;
|
|
371
|
+
const payload = structuredClone(storedPayload);
|
|
372
|
+
if (Object.hasOwn(payload, "result")) {
|
|
373
|
+
payload.result = (0, import_task_registry.sanitizeTaskResultForWire)(payload.result, ref);
|
|
374
|
+
}
|
|
375
|
+
return taskPushIntentFingerprintForPayload(snapshot, payload);
|
|
376
|
+
}
|
|
377
|
+
function taskPushLegacyIntentFingerprint(snapshot, storedResult) {
|
|
378
|
+
const { timestamp: _generatedTimestamp, ...storedPayload } = snapshot.payload;
|
|
379
|
+
const payload = structuredClone(storedPayload);
|
|
380
|
+
payload.result = structuredClone(storedResult);
|
|
381
|
+
return taskPushIntentFingerprintForPayload(snapshot, payload);
|
|
382
|
+
}
|
|
383
|
+
function taskPushIntentFingerprintForPayload(snapshot, payload) {
|
|
384
|
+
const domain = {
|
|
322
385
|
url: snapshot.url,
|
|
323
386
|
payload,
|
|
324
387
|
retries: snapshot.retries,
|
|
325
388
|
authentication: snapshot.authentication.kind === "none" ? { kind: "none" } : { kind: "protected", fingerprint: snapshot.authentication.fingerprint },
|
|
326
389
|
...snapshot.payloadToken && { payloadToken: { fingerprint: snapshot.payloadToken.fingerprint } }
|
|
327
|
-
}
|
|
390
|
+
};
|
|
391
|
+
(0, import_well_formed_unicode.assertWellFormedUnicode)(domain, "Task push settlement fingerprint");
|
|
392
|
+
return (0, import_jcs.canonicalJsonSha256)(domain);
|
|
328
393
|
}
|
|
329
394
|
function validatePush(push) {
|
|
330
395
|
if (!push || typeof push !== "object") {
|
|
331
396
|
throw new TaskPushSettlementConfigurationError("push settlement config is required");
|
|
332
397
|
}
|
|
398
|
+
(0, import_well_formed_unicode.assertWellFormedUnicode)(push, "Task push settlement config");
|
|
333
399
|
const allowHttp = process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development" || process.env.ADCP_DECISIONING_ALLOW_HTTP_WEBHOOKS === "1";
|
|
334
400
|
let parsedUrl;
|
|
335
401
|
try {
|
|
@@ -412,8 +478,14 @@ async function readOutbox(db, table, key, lock) {
|
|
|
412
478
|
);
|
|
413
479
|
return found.rows[0];
|
|
414
480
|
}
|
|
415
|
-
function outboxMatchesPrepared(existing,
|
|
416
|
-
|
|
481
|
+
function outboxMatchesPrepared(existing, intentFingerprint, ref) {
|
|
482
|
+
if (existing.intent_fingerprint === intentFingerprint) return true;
|
|
483
|
+
if (existing.state === "settled") return false;
|
|
484
|
+
try {
|
|
485
|
+
return taskPushIntentFingerprint(existing.snapshot, ref) === intentFingerprint;
|
|
486
|
+
} catch {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
417
489
|
}
|
|
418
490
|
function throwSettlementFailure(cause) {
|
|
419
491
|
if (cause instanceof TaskPushSettlementConfigurationError) throw cause;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { canonicalJsonSha256 } from "../../../utils/jcs.mjs";
|
|
3
3
|
import { isAdcpVersionAtLeast, isValidAdcpVersion } from "../../../utils/adcp-version-config.mjs";
|
|
4
|
+
import { assertWellFormedUnicode } from "../../../utils/well-formed-unicode.mjs";
|
|
4
5
|
import { sanitizeStructuredAdcpError } from "../../errors.mjs";
|
|
5
6
|
import { quoteWebhookTable } from "../../webhook-delivery/common.mjs";
|
|
6
7
|
import {
|
|
@@ -30,17 +31,20 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
30
31
|
"Crash-safe task settlement requires a pg Pool with connect(); a query-only adapter cannot own a transaction"
|
|
31
32
|
);
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
const registryId = options.registry.registryId;
|
|
35
|
+
if (typeof registryId !== "string" || registryId.length === 0) {
|
|
34
36
|
throw new TypeError("Crash-safe task settlement requires task registry storageId/registryId binding");
|
|
35
37
|
}
|
|
36
38
|
if (typeof options.publisherScope !== "string" || options.publisherScope.length === 0) {
|
|
37
39
|
throw new TypeError("publisherScope must be a non-empty string");
|
|
38
40
|
}
|
|
41
|
+
assertWellFormedUnicode(registryId, "Task settlement registryId");
|
|
42
|
+
assertWellFormedUnicode(options.publisherScope, "Task settlement publisherScope");
|
|
39
43
|
const outboxOptions = options.outbox ?? {};
|
|
40
44
|
const outboxTable = quoteWebhookTable(outboxOptions.tableName ?? "adcp_webhook_outbox");
|
|
41
45
|
const claimScope = {
|
|
42
46
|
publisherScope: options.publisherScope,
|
|
43
|
-
tenantScope: stableRegistryScope(
|
|
47
|
+
tenantScope: stableRegistryScope(registryId, binding.namespace)
|
|
44
48
|
};
|
|
45
49
|
const backend = pgWebhookDeliveryRecoveryBackend(binding.pool, { ...outboxOptions, claimScope });
|
|
46
50
|
const recovery = createWebhookDeliveryRecovery({
|
|
@@ -53,22 +57,35 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
53
57
|
durability: "durable",
|
|
54
58
|
recovery,
|
|
55
59
|
async settle(ref, terminal, push) {
|
|
56
|
-
|
|
60
|
+
const settlementRef = snapshotSettlementRef(ref);
|
|
61
|
+
if (settlementRef.registryId !== registryId) {
|
|
57
62
|
return { outcome: "not_found_in_scope", delivery: "not_applicable" };
|
|
58
63
|
}
|
|
64
|
+
let clonedTerminal;
|
|
65
|
+
let clonedPush;
|
|
66
|
+
try {
|
|
67
|
+
clonedTerminal = structuredClone(terminal);
|
|
68
|
+
clonedPush = structuredClone(push);
|
|
69
|
+
assertWellFormedUnicode(clonedTerminal, "Task terminal result/error");
|
|
70
|
+
assertWellFormedUnicode(clonedPush, "Task push settlement config");
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
throw new TaskPushSettlementConfigurationError("Task settlement inputs must contain serializable JSON", {
|
|
73
|
+
cause
|
|
74
|
+
});
|
|
75
|
+
}
|
|
59
76
|
const deliveryKey = {
|
|
60
77
|
...claimScope,
|
|
61
|
-
deliveryId: stableScope("task-webhook",
|
|
78
|
+
deliveryId: stableScope("task-webhook", settlementRef)
|
|
62
79
|
};
|
|
63
80
|
const transactionalPool = pool;
|
|
64
81
|
let observed;
|
|
65
82
|
try {
|
|
66
|
-
observed = await readTaskRow(transactionalPool, binding.tableName, binding.namespace,
|
|
83
|
+
observed = await readTaskRow(transactionalPool, binding.tableName, binding.namespace, settlementRef, false);
|
|
67
84
|
} catch (cause) {
|
|
68
85
|
throwSettlementFailure(cause);
|
|
69
86
|
}
|
|
70
87
|
if (!observed) return { outcome: "not_found_in_scope", delivery: "not_applicable" };
|
|
71
|
-
validatePush(
|
|
88
|
+
validatePush(clonedPush);
|
|
72
89
|
if (observed.has_webhook !== true) {
|
|
73
90
|
throw new TaskPushSettlementConfigurationError(
|
|
74
91
|
"Crash-safe push settlement requires a task created with hasWebhook: true"
|
|
@@ -77,8 +94,9 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
77
94
|
let result;
|
|
78
95
|
let error;
|
|
79
96
|
try {
|
|
80
|
-
result =
|
|
81
|
-
error =
|
|
97
|
+
result = clonedTerminal.status === "completed" ? sanitizeTaskResultForWire(clonedTerminal.result, settlementRef) : clonedTerminal.result === void 0 ? { errors: [sanitizeStructuredAdcpError(clonedTerminal.error)] } : sanitizeTaskResultForWire(clonedTerminal.result, settlementRef);
|
|
98
|
+
error = clonedTerminal.status === "failed" ? sanitizeStructuredAdcpError(clonedTerminal.error) : void 0;
|
|
99
|
+
assertWellFormedUnicode({ result, error }, "Task terminal result/error");
|
|
82
100
|
canonicalJsonSha256({ result, error });
|
|
83
101
|
assertPayloadSize(result, error);
|
|
84
102
|
} catch (cause) {
|
|
@@ -87,7 +105,7 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
87
105
|
cause
|
|
88
106
|
});
|
|
89
107
|
}
|
|
90
|
-
if (TERMINAL.has(observed.status) && !terminalMatches(observed,
|
|
108
|
+
if (TERMINAL.has(observed.status) && !terminalMatches(observed, clonedTerminal.status, result, error, settlementRef)) {
|
|
91
109
|
return {
|
|
92
110
|
outcome: "already_terminal",
|
|
93
111
|
status: observed.status,
|
|
@@ -114,27 +132,27 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
114
132
|
const existingTimestamp = observedOutbox?.state === "pending" ? observedOutbox.snapshot.payload.timestamp : void 0;
|
|
115
133
|
const payload = {
|
|
116
134
|
idempotency_key: `pending.${deliveryKey.deliveryId.slice(-48)}`,
|
|
117
|
-
operation_id: resolveOperationId(
|
|
118
|
-
task_id:
|
|
135
|
+
operation_id: resolveOperationId(clonedPush, observed.tool, settlementRef.taskId),
|
|
136
|
+
task_id: settlementRef.taskId,
|
|
119
137
|
task_type: observed.tool,
|
|
120
|
-
status:
|
|
138
|
+
status: clonedTerminal.status,
|
|
121
139
|
timestamp: typeof existingTimestamp === "string" ? existingTimestamp : (/* @__PURE__ */ new Date()).toISOString(),
|
|
122
140
|
protocol: protocolForTool(observed.tool),
|
|
123
141
|
result,
|
|
124
|
-
...
|
|
125
|
-
...
|
|
142
|
+
...clonedPush.token !== void 0 && { token: clonedPush.token },
|
|
143
|
+
...clonedTerminal.status === "failed" && { message: error.message }
|
|
126
144
|
};
|
|
127
145
|
let prepared;
|
|
128
146
|
try {
|
|
129
147
|
prepared = await recovery.prepare(
|
|
130
148
|
deliveryKey,
|
|
131
149
|
{
|
|
132
|
-
url:
|
|
150
|
+
url: clonedPush.url,
|
|
133
151
|
payload,
|
|
134
|
-
authentication:
|
|
152
|
+
authentication: clonedPush.authentication ?? null,
|
|
135
153
|
retries
|
|
136
154
|
},
|
|
137
|
-
{ protectPayloadToken:
|
|
155
|
+
{ protectPayloadToken: clonedPush.token !== void 0 }
|
|
138
156
|
);
|
|
139
157
|
} catch (cause) {
|
|
140
158
|
if (cause instanceof WebhookAuthenticationProtectionError) throwSettlementFailure(cause);
|
|
@@ -142,12 +160,12 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
142
160
|
cause
|
|
143
161
|
});
|
|
144
162
|
}
|
|
145
|
-
const intentFingerprint = taskPushIntentFingerprint(prepared.snapshot);
|
|
163
|
+
const intentFingerprint = taskPushIntentFingerprint(prepared.snapshot, settlementRef);
|
|
146
164
|
let client;
|
|
147
165
|
try {
|
|
148
166
|
client = await transactionalPool.connect();
|
|
149
167
|
await client.query("BEGIN");
|
|
150
|
-
const row = await readTaskRow(client, binding.tableName, binding.namespace,
|
|
168
|
+
const row = await readTaskRow(client, binding.tableName, binding.namespace, settlementRef, true);
|
|
151
169
|
if (!row) {
|
|
152
170
|
await client.query("ROLLBACK");
|
|
153
171
|
return { outcome: "not_found_in_scope", delivery: "not_applicable" };
|
|
@@ -165,7 +183,7 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
165
183
|
`Task type ${row.tool} cannot be emitted by the closed AdCP task-webhook schema`
|
|
166
184
|
);
|
|
167
185
|
}
|
|
168
|
-
const compatible = terminalMatches(row,
|
|
186
|
+
const compatible = terminalMatches(row, clonedTerminal.status, result, error, settlementRef);
|
|
169
187
|
if (TERMINAL.has(row.status) && !compatible) {
|
|
170
188
|
await client.query("COMMIT");
|
|
171
189
|
return {
|
|
@@ -187,7 +205,9 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
187
205
|
if (!TERMINAL.has(row.status)) {
|
|
188
206
|
throw new Error("Settled webhook outbox row exists for a non-terminal task");
|
|
189
207
|
}
|
|
190
|
-
|
|
208
|
+
const matchesCurrentIntent = existing.intent_fingerprint === intentFingerprint;
|
|
209
|
+
const matchesLegacyArtifact = existing.intent_fingerprint !== null && existing.intent_fingerprint === taskPushLegacyIntentFingerprint(prepared.snapshot, row.result);
|
|
210
|
+
if (existing.intent_fingerprint !== null && !matchesCurrentIntent && !matchesLegacyArtifact) {
|
|
191
211
|
throw new TaskPushSettlementConfigurationError(
|
|
192
212
|
"Terminal webhook delivery identity is already bound to a conflicting route or payload"
|
|
193
213
|
);
|
|
@@ -200,7 +220,7 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
200
220
|
delivery: deliveryState(existing)
|
|
201
221
|
};
|
|
202
222
|
}
|
|
203
|
-
if (existing && !outboxMatchesPrepared(existing,
|
|
223
|
+
if (existing && !outboxMatchesPrepared(existing, intentFingerprint, settlementRef)) {
|
|
204
224
|
throw new TaskPushSettlementConfigurationError(
|
|
205
225
|
"Terminal webhook delivery identity is already bound to a conflicting route or payload"
|
|
206
226
|
);
|
|
@@ -224,7 +244,7 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
224
244
|
);
|
|
225
245
|
if ((write.rowCount ?? 0) !== 1) {
|
|
226
246
|
const raced = await readOutbox(client, outboxTable, deliveryKey, true);
|
|
227
|
-
if (!raced || !outboxMatchesPrepared(raced,
|
|
247
|
+
if (!raced || !outboxMatchesPrepared(raced, intentFingerprint, settlementRef)) {
|
|
228
248
|
throw new TaskPushSettlementConfigurationError(
|
|
229
249
|
"Terminal webhook delivery identity is already bound to a conflicting route or payload"
|
|
230
250
|
);
|
|
@@ -236,22 +256,28 @@ function createPostgresTaskSettlementCoordinator(options) {
|
|
|
236
256
|
}
|
|
237
257
|
}
|
|
238
258
|
if (!TERMINAL.has(row.status)) {
|
|
239
|
-
const update =
|
|
259
|
+
const update = clonedTerminal.status === "completed" ? await client.query(
|
|
240
260
|
`UPDATE ${binding.tableName}
|
|
241
261
|
SET status = 'completed', result = $5::jsonb, error = NULL,
|
|
242
262
|
status_message = NULL, updated_at = clock_timestamp()
|
|
243
263
|
WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,
|
|
244
|
-
[
|
|
264
|
+
[
|
|
265
|
+
settlementRef.taskId,
|
|
266
|
+
binding.namespace,
|
|
267
|
+
settlementRef.accountId,
|
|
268
|
+
settlementRef.ownerScope,
|
|
269
|
+
JSON.stringify(result)
|
|
270
|
+
]
|
|
245
271
|
) : await client.query(
|
|
246
272
|
`UPDATE ${binding.tableName}
|
|
247
273
|
SET status = 'failed', result = $5::jsonb, error = $6::jsonb,
|
|
248
274
|
status_message = $7, updated_at = clock_timestamp()
|
|
249
275
|
WHERE task_id = $1 AND registry_namespace = $2 AND account_id = $3 AND owner_scope = $4`,
|
|
250
276
|
[
|
|
251
|
-
|
|
277
|
+
settlementRef.taskId,
|
|
252
278
|
binding.namespace,
|
|
253
|
-
|
|
254
|
-
|
|
279
|
+
settlementRef.accountId,
|
|
280
|
+
settlementRef.ownerScope,
|
|
255
281
|
JSON.stringify(result),
|
|
256
282
|
JSON.stringify(error),
|
|
257
283
|
error.message
|
|
@@ -283,9 +309,33 @@ async function completeScopedPushTask(coordinator, ref, push, result) {
|
|
|
283
309
|
async function failScopedPushTask(coordinator, ref, push, error, result) {
|
|
284
310
|
return coordinator.settle(ref, { status: "failed", error, ...result !== void 0 && { result } }, push);
|
|
285
311
|
}
|
|
286
|
-
function
|
|
312
|
+
function snapshotSettlementRef(ref) {
|
|
313
|
+
const cloned = structuredClone(ref);
|
|
314
|
+
const snapshot = {
|
|
315
|
+
registryId: requireSettlementRefPart(cloned.registryId, "registryId"),
|
|
316
|
+
accountId: requireSettlementRefPart(cloned.accountId, "accountId"),
|
|
317
|
+
ownerScope: requireSettlementRefPart(cloned.ownerScope, "ownerScope"),
|
|
318
|
+
taskId: requireSettlementRefPart(cloned.taskId, "taskId")
|
|
319
|
+
};
|
|
320
|
+
assertWellFormedUnicode(snapshot, "Task settlement reference");
|
|
321
|
+
return snapshot;
|
|
322
|
+
}
|
|
323
|
+
function requireSettlementRefPart(value, field) {
|
|
324
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
325
|
+
throw new TypeError(`Task settlement ${field} must be a non-empty string`);
|
|
326
|
+
}
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
function terminalMatches(row, status, result, error, ref) {
|
|
287
330
|
if (row.status !== status) return false;
|
|
288
|
-
|
|
331
|
+
try {
|
|
332
|
+
assertWellFormedUnicode({ result: row.result, error: row.error }, "Stored task terminal result/error");
|
|
333
|
+
const storedResult = sanitizeTaskResultForWire(structuredClone(row.result), ref);
|
|
334
|
+
const storedError = status === "failed" ? sanitizeStructuredAdcpError(structuredClone(row.error)) : void 0;
|
|
335
|
+
return canonicalJsonSha256(storedResult) === canonicalJsonSha256(result) && (status === "completed" || canonicalJsonSha256(storedError) === canonicalJsonSha256(error));
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
289
339
|
}
|
|
290
340
|
function stableScope(prefix, ref) {
|
|
291
341
|
const digest = createHash("sha256").update(JSON.stringify([ref.registryId, ref.accountId, ref.ownerScope, ref.taskId])).digest("hex");
|
|
@@ -295,20 +345,36 @@ function stableRegistryScope(registryId, namespace) {
|
|
|
295
345
|
const digest = createHash("sha256").update(JSON.stringify([registryId, namespace])).digest("hex");
|
|
296
346
|
return `task-registry:${digest}`;
|
|
297
347
|
}
|
|
298
|
-
function taskPushIntentFingerprint(snapshot) {
|
|
299
|
-
const { timestamp: _generatedTimestamp, ...
|
|
300
|
-
|
|
348
|
+
function taskPushIntentFingerprint(snapshot, ref) {
|
|
349
|
+
const { timestamp: _generatedTimestamp, ...storedPayload } = snapshot.payload;
|
|
350
|
+
const payload = structuredClone(storedPayload);
|
|
351
|
+
if (Object.hasOwn(payload, "result")) {
|
|
352
|
+
payload.result = sanitizeTaskResultForWire(payload.result, ref);
|
|
353
|
+
}
|
|
354
|
+
return taskPushIntentFingerprintForPayload(snapshot, payload);
|
|
355
|
+
}
|
|
356
|
+
function taskPushLegacyIntentFingerprint(snapshot, storedResult) {
|
|
357
|
+
const { timestamp: _generatedTimestamp, ...storedPayload } = snapshot.payload;
|
|
358
|
+
const payload = structuredClone(storedPayload);
|
|
359
|
+
payload.result = structuredClone(storedResult);
|
|
360
|
+
return taskPushIntentFingerprintForPayload(snapshot, payload);
|
|
361
|
+
}
|
|
362
|
+
function taskPushIntentFingerprintForPayload(snapshot, payload) {
|
|
363
|
+
const domain = {
|
|
301
364
|
url: snapshot.url,
|
|
302
365
|
payload,
|
|
303
366
|
retries: snapshot.retries,
|
|
304
367
|
authentication: snapshot.authentication.kind === "none" ? { kind: "none" } : { kind: "protected", fingerprint: snapshot.authentication.fingerprint },
|
|
305
368
|
...snapshot.payloadToken && { payloadToken: { fingerprint: snapshot.payloadToken.fingerprint } }
|
|
306
|
-
}
|
|
369
|
+
};
|
|
370
|
+
assertWellFormedUnicode(domain, "Task push settlement fingerprint");
|
|
371
|
+
return canonicalJsonSha256(domain);
|
|
307
372
|
}
|
|
308
373
|
function validatePush(push) {
|
|
309
374
|
if (!push || typeof push !== "object") {
|
|
310
375
|
throw new TaskPushSettlementConfigurationError("push settlement config is required");
|
|
311
376
|
}
|
|
377
|
+
assertWellFormedUnicode(push, "Task push settlement config");
|
|
312
378
|
const allowHttp = process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development" || process.env.ADCP_DECISIONING_ALLOW_HTTP_WEBHOOKS === "1";
|
|
313
379
|
let parsedUrl;
|
|
314
380
|
try {
|
|
@@ -391,8 +457,14 @@ async function readOutbox(db, table, key, lock) {
|
|
|
391
457
|
);
|
|
392
458
|
return found.rows[0];
|
|
393
459
|
}
|
|
394
|
-
function outboxMatchesPrepared(existing,
|
|
395
|
-
|
|
460
|
+
function outboxMatchesPrepared(existing, intentFingerprint, ref) {
|
|
461
|
+
if (existing.intent_fingerprint === intentFingerprint) return true;
|
|
462
|
+
if (existing.state === "settled") return false;
|
|
463
|
+
try {
|
|
464
|
+
return taskPushIntentFingerprint(existing.snapshot, ref) === intentFingerprint;
|
|
465
|
+
} catch {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
396
468
|
}
|
|
397
469
|
function throwSettlementFailure(cause) {
|
|
398
470
|
if (cause instanceof TaskPushSettlementConfigurationError) throw cause;
|