@tangle-network/agent-provider-tangle 1.1.7 → 1.1.8

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,530 @@
1
+ import { WorkspaceCheckpointRefSchema, canonicalCandidateDigest, } from "@tangle-network/agent-interface";
2
+ import { awaitWithSignal, boundedIdentifier, MAX_LIST_RESULTS, SANDBOX_LIST_PAGE_SIZE, cloneJson, safeIdentifier, safeString, } from "./tangle-contract-safety.js";
3
+ import { checkpointMarkerTags, legacyCheckpointMarkerTags, forkMarkerMetadata, markerBelongsToSource, checkpointMarkerBelongsToSource, checkpointMarkerFromTags, forkMarkerFromMetadata, } from "./tangle-workspace-markers.js";
4
+ export function checkpointRecordFromSnapshot(request, snapshot) {
5
+ try {
6
+ const createdAt = isoDate(snapshot.createdAt);
7
+ const checkpoint = WorkspaceCheckpointRefSchema.parse({
8
+ checkpointId: boundedIdentifier(snapshot.snapshotId, "Tangle checkpoint id"),
9
+ provider: request.source.provider,
10
+ source: request.source,
11
+ idempotencyKey: request.idempotencyKey,
12
+ requestDigest: request.requestDigest,
13
+ createdAt,
14
+ ...(request.metadata === undefined
15
+ ? {}
16
+ : { metadata: cloneJson(request.metadata) }),
17
+ });
18
+ return {
19
+ request,
20
+ checkpoint,
21
+ snapshotId: checkpoint.checkpointId,
22
+ };
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ export function validSnapshotResult(result) {
29
+ return (!!result &&
30
+ safeIdentifier(result.snapshotId) !== undefined &&
31
+ validDate(result.createdAt) &&
32
+ Array.isArray(result.tags) &&
33
+ result.tags.every((tag) => safeString(tag) !== undefined));
34
+ }
35
+ function validSnapshotInfo(snapshot, sandboxId) {
36
+ return (validSnapshotResult(snapshot) &&
37
+ safeIdentifier(snapshot.sandboxId) !== undefined &&
38
+ (sandboxId === undefined || snapshot.sandboxId === sandboxId));
39
+ }
40
+ function validOperationRecord(value) {
41
+ return value !== null && typeof value === "object" && !Array.isArray(value);
42
+ }
43
+ function validOperationDate(value) {
44
+ return ((typeof value === "string" || value instanceof Date) && validDate(value));
45
+ }
46
+ function validSnapshotOperationResult(value) {
47
+ return (validOperationRecord(value) &&
48
+ safeIdentifier(value.snapshotId) !== undefined &&
49
+ validOperationDate(value.createdAt));
50
+ }
51
+ function validTaggedSnapshotOperationResult(value) {
52
+ return (validSnapshotOperationResult(value) &&
53
+ Array.isArray(value.tags) &&
54
+ value.tags.every((tag) => safeString(tag) !== undefined));
55
+ }
56
+ function validForkOperationChildResult(value) {
57
+ return (validOperationRecord(value) &&
58
+ safeIdentifier(value.sandboxId ?? value.id) !== undefined &&
59
+ (value.createdAt === undefined ||
60
+ value.createdAt === null ||
61
+ validOperationDate(value.createdAt)));
62
+ }
63
+ /**
64
+ * Ask the Sandbox operation ledger whether a marked checkpoint settled.
65
+ *
66
+ * A marker only names a candidate resource. Nothing is returned to a caller
67
+ * until the ledger reports the operation succeeded.
68
+ */
69
+ async function checkpointOperationLookup(box, marker, signal) {
70
+ signal?.throwIfAborted();
71
+ const lookup = await awaitWithSignal(box.getSnapshotOperation?.(marker.idempotencyKey, {
72
+ tags: marker.legacy
73
+ ? legacyCheckpointMarkerTags(marker.request)
74
+ : checkpointMarkerTags(marker.request),
75
+ }), signal);
76
+ return lookup;
77
+ }
78
+ /** Read the durable record by its owner-scoped key when no request body remains. */
79
+ async function checkpointOperationLookupByKey(box, idempotencyKey, signal) {
80
+ signal?.throwIfAborted();
81
+ return await awaitWithSignal(box.getSnapshotOperation?.(idempotencyKey), signal);
82
+ }
83
+ async function checkpointOperationSucceeded(box, marker, signal) {
84
+ const lookup = await checkpointOperationLookup(box, marker, signal);
85
+ return (lookup?.outcome === "found" &&
86
+ lookup.kind === "checkpoint" &&
87
+ lookup.state === "succeeded");
88
+ }
89
+ /** Confirm a fork child through its marker or the legacy fork ledger. */
90
+ async function forkOperationLookup(box, marker, signal) {
91
+ if (marker.materialization === "snapshot") {
92
+ return {
93
+ outcome: "found",
94
+ kind: "fork",
95
+ state: "succeeded",
96
+ };
97
+ }
98
+ const lookup = await awaitWithSignal(box.getForkOperation?.(marker.idempotencyKey, {
99
+ count: 1,
100
+ metadata: forkMarkerMetadata(marker.request, marker.materialization),
101
+ }), signal);
102
+ return lookup;
103
+ }
104
+ async function forkOperationSucceeded(box, marker, signal) {
105
+ const lookup = await forkOperationLookup(box, marker, signal);
106
+ return (lookup?.outcome === "found" &&
107
+ lookup.kind === "fork" &&
108
+ lookup.state === "succeeded");
109
+ }
110
+ async function findCheckpointByKey(box, provider, key, signal) {
111
+ let snapshots;
112
+ try {
113
+ signal?.throwIfAborted();
114
+ const listed = await awaitWithSignal(box.listSnapshots?.(), signal);
115
+ if (!Array.isArray(listed))
116
+ return undefined;
117
+ snapshots = listed;
118
+ }
119
+ catch {
120
+ signal?.throwIfAborted();
121
+ return undefined;
122
+ }
123
+ if (!Array.isArray(snapshots) || snapshots.length > MAX_LIST_RESULTS) {
124
+ return undefined;
125
+ }
126
+ const snapshotIds = new Set();
127
+ let found;
128
+ let unresolved = false;
129
+ for (const snapshot of snapshots) {
130
+ if (!validSnapshotInfo(snapshot, box.id))
131
+ return undefined;
132
+ if (snapshotIds.has(snapshot.snapshotId))
133
+ return undefined;
134
+ snapshotIds.add(snapshot.snapshotId);
135
+ const marker = checkpointMarkerFromTags(snapshot.tags, key);
136
+ if (!marker)
137
+ continue;
138
+ if (!checkpointMarkerBelongsToSource(marker, provider, box.id)) {
139
+ return undefined;
140
+ }
141
+ try {
142
+ const lookup = await checkpointOperationLookup(box, marker, signal);
143
+ if (lookup?.outcome === "found" &&
144
+ lookup.kind === "checkpoint" &&
145
+ lookup.state === "succeeded") {
146
+ const authoritative = snapshotFromOperationResult(snapshot, lookup);
147
+ if (authoritative === undefined)
148
+ return undefined;
149
+ if (found !== undefined)
150
+ return undefined;
151
+ found = { state: "found", snapshot: authoritative, marker };
152
+ continue;
153
+ }
154
+ unresolved = true;
155
+ }
156
+ catch {
157
+ signal?.throwIfAborted();
158
+ return undefined;
159
+ }
160
+ }
161
+ if (found !== undefined)
162
+ return found;
163
+ if (unresolved)
164
+ return undefined;
165
+ // Some storage backends retain the snapshot but omit caller tags from a
166
+ // later inventory read. The owner-scoped operation record retains the exact
167
+ // acknowledgement, including those tags. Bind that record to a currently
168
+ // live snapshot id before recovering it; neither record is sufficient alone.
169
+ let lookup;
170
+ try {
171
+ lookup = await checkpointOperationLookupByKey(box, key, signal);
172
+ }
173
+ catch {
174
+ signal?.throwIfAborted();
175
+ return undefined;
176
+ }
177
+ if (lookup?.outcome === "not_found" && lookup.kind === "checkpoint") {
178
+ return null;
179
+ }
180
+ if (lookup?.outcome !== "found" ||
181
+ lookup.kind !== "checkpoint" ||
182
+ lookup.state !== "succeeded") {
183
+ return undefined;
184
+ }
185
+ if (snapshots.length === 0)
186
+ return { state: "retired" };
187
+ if (!validTaggedSnapshotOperationResult(lookup.result))
188
+ return undefined;
189
+ const live = snapshots.filter((snapshot) => snapshot.snapshotId === lookup.result?.snapshotId);
190
+ if (live.length === 0)
191
+ return { state: "retired" };
192
+ if (live.length !== 1)
193
+ return undefined;
194
+ const marker = checkpointMarkerFromTags(lookup.result.tags, key);
195
+ if (!marker ||
196
+ !checkpointMarkerBelongsToSource(marker, provider, box.id)) {
197
+ return undefined;
198
+ }
199
+ const authoritative = snapshotFromOperationResult(live[0], lookup);
200
+ return authoritative === undefined
201
+ ? undefined
202
+ : { state: "found", snapshot: authoritative, marker };
203
+ }
204
+ /** Normalize one remote checkpoint recovery attempt for every caller. */
205
+ export async function reconcileCheckpoint(box, provider, request, signal) {
206
+ const recovered = await findCheckpointByKey(box, provider, request.idempotencyKey, signal);
207
+ if (recovered === undefined) {
208
+ return { state: "undecided", reason: "inventory_unavailable" };
209
+ }
210
+ if (recovered === null)
211
+ return { state: "absent" };
212
+ if (recovered.state === "retired")
213
+ return recovered;
214
+ if (recovered.marker.requestDigest !== request.requestDigest) {
215
+ return {
216
+ state: "conflict",
217
+ existingRequestDigest: recovered.marker.requestDigest,
218
+ };
219
+ }
220
+ const record = checkpointRecordFromSnapshot(recovered.marker.request, recovered.snapshot);
221
+ return record === undefined
222
+ ? { state: "undecided", reason: "metadata_invalid" }
223
+ : { state: "found", record };
224
+ }
225
+ /**
226
+ * Prefer the durable operation result over inventory metadata.
227
+ *
228
+ * Snapshot inventory and the operation ledger can expose different creation
229
+ * timestamps. The ledger result is the acknowledgement returned by the
230
+ * idempotent operation, so recovery must rebuild the exact checkpoint ref
231
+ * from it when the service provides that result.
232
+ */
233
+ function snapshotFromOperationResult(snapshot, lookup) {
234
+ if (lookup.result === undefined)
235
+ return snapshot;
236
+ if (!validSnapshotOperationResult(lookup.result) ||
237
+ lookup.result.snapshotId !== snapshot.snapshotId) {
238
+ return undefined;
239
+ }
240
+ return { ...snapshot, createdAt: lookup.result.createdAt };
241
+ }
242
+ /**
243
+ * Confirm that one snapshot id is a settled checkpoint this provider created.
244
+ *
245
+ * `expected` binds the answer to a specific checkpoint reference. A reference
246
+ * that does not match its marker is absent, not unknown: the caller supplied a
247
+ * checkpoint this source never produced.
248
+ */
249
+ export async function findManagedCheckpoint(box, provider, id, expected, signal) {
250
+ try {
251
+ const snapshots = await awaitWithSignal(box.listSnapshots?.(), signal);
252
+ if (!Array.isArray(snapshots) || snapshots.length > MAX_LIST_RESULTS) {
253
+ return "unknown";
254
+ }
255
+ const snapshot = snapshots.find((candidate) => candidate.snapshotId === id);
256
+ if (!snapshot)
257
+ return false;
258
+ if (!validSnapshotInfo(snapshot, box.id))
259
+ return "unknown";
260
+ const marker = checkpointMarkerFromTags(snapshot.tags, expected?.idempotencyKey);
261
+ if (!marker)
262
+ return expected ? false : "unknown";
263
+ if (marker.request.source.provider !== provider ||
264
+ marker.request.source.environmentId !== box.id) {
265
+ return expected ? false : "unknown";
266
+ }
267
+ if (expected &&
268
+ (marker.requestDigest !== expected.requestDigest ||
269
+ canonicalCandidateDigest(marker.request.source) !==
270
+ canonicalCandidateDigest(expected.source))) {
271
+ return false;
272
+ }
273
+ return (await checkpointOperationSucceeded(box, marker, signal))
274
+ ? true
275
+ : "unknown";
276
+ }
277
+ catch {
278
+ signal?.throwIfAborted();
279
+ return "unknown";
280
+ }
281
+ }
282
+ export async function findForkByKey(client, box, provider, key, signal) {
283
+ const candidates = await listMarkedForkChildren(client, box, provider, key, signal);
284
+ if (candidates === undefined)
285
+ return undefined;
286
+ let unresolved = false;
287
+ for (const candidate of candidates) {
288
+ try {
289
+ const lookup = await forkOperationLookup(box, candidate.marker, signal);
290
+ if (lookup?.outcome === "found" &&
291
+ lookup.kind === "fork" &&
292
+ lookup.state === "succeeded") {
293
+ const authoritative = childFromOperationResult(candidate.child, lookup);
294
+ if (authoritative === undefined)
295
+ return undefined;
296
+ return { ...authoritative, marker: candidate.marker };
297
+ }
298
+ unresolved = true;
299
+ }
300
+ catch {
301
+ signal?.throwIfAborted();
302
+ return undefined;
303
+ }
304
+ }
305
+ return unresolved ? undefined : null;
306
+ }
307
+ /**
308
+ * Prefer the durable fork result over account-inventory metadata.
309
+ *
310
+ * Fork inventory can report a child timestamp from a later registry read. The
311
+ * operation ledger stores the original child acknowledgement, which is the
312
+ * stable value required to replay one exact fork reference after a restart.
313
+ * Some Sandbox responses omit that timestamp, so the validated inventory
314
+ * record supplies it only when the operation result does not.
315
+ */
316
+ function childFromOperationResult(child, lookup) {
317
+ if (lookup.result === undefined) {
318
+ return { child, createdAt: child.createdAt };
319
+ }
320
+ const result = lookup.result;
321
+ if (!validOperationRecord(result))
322
+ return undefined;
323
+ const children = result.children;
324
+ if (!Array.isArray(children))
325
+ return undefined;
326
+ const operationChild = children.find((candidate) => validForkOperationChildResult(candidate) &&
327
+ (candidate.sandboxId ?? candidate.id) === child.id);
328
+ if (!operationChild)
329
+ return undefined;
330
+ const createdAt = operationChild.createdAt ?? child.createdAt;
331
+ if (!validOperationDate(createdAt))
332
+ return undefined;
333
+ return { child, createdAt };
334
+ }
335
+ export async function findForkChildById(client, box, provider, id, signal) {
336
+ try {
337
+ if (typeof client.get !== "function")
338
+ return undefined;
339
+ const child = await awaitWithSignal(client.get(id, signal ? { signal } : undefined), signal);
340
+ if (child === null)
341
+ return null;
342
+ if (child.id !== id)
343
+ return undefined;
344
+ const marker = forkMarkerFromMetadata(child.metadata);
345
+ if (!marker || !markerBelongsToSource(marker, provider, box.id))
346
+ return undefined;
347
+ return (await forkOperationSucceeded(box, marker, signal))
348
+ ? child
349
+ : undefined;
350
+ }
351
+ catch {
352
+ signal?.throwIfAborted();
353
+ return undefined;
354
+ }
355
+ }
356
+ /**
357
+ * Resolve a complete child identity when an acknowledgement omits durable data.
358
+ *
359
+ * A branch response can precede a richer registry read during a rolling
360
+ * deployment. Recover the exact child when its creation time or provider
361
+ * marker is absent. Never invent either field from the request.
362
+ */
363
+ export async function completeForkChild(client, child, signal) {
364
+ if (child.createdAt !== undefined &&
365
+ forkMarkerFromMetadata(child.metadata) !== undefined) {
366
+ return child;
367
+ }
368
+ if (typeof client.get !== "function" ||
369
+ safeIdentifier(child.id) === undefined) {
370
+ return undefined;
371
+ }
372
+ try {
373
+ const resolved = await awaitWithSignal(client.get(child.id, signal ? { signal } : undefined), signal);
374
+ if (!resolved ||
375
+ resolved.id !== child.id ||
376
+ resolved.createdAt === undefined) {
377
+ return undefined;
378
+ }
379
+ return resolved;
380
+ }
381
+ catch {
382
+ signal?.throwIfAborted();
383
+ return undefined;
384
+ }
385
+ }
386
+ export async function findBlockingForks(box, client, provider, checkpointId, signal) {
387
+ const candidates = await listMarkedForkChildren(client, box, provider, undefined, signal);
388
+ if (candidates === undefined)
389
+ return undefined;
390
+ const blocking = new Set();
391
+ for (const { child, marker } of candidates) {
392
+ if (marker.request.checkpoint.checkpointId !== checkpointId)
393
+ continue;
394
+ try {
395
+ // A candidate that cannot be confirmed leaves the dependency set
396
+ // unknown, so cleanup must not proceed on a partial answer.
397
+ if (!(await forkOperationSucceeded(box, marker, signal)))
398
+ return undefined;
399
+ blocking.add(child.id);
400
+ }
401
+ catch {
402
+ signal?.throwIfAborted();
403
+ return undefined;
404
+ }
405
+ }
406
+ return [...blocking].sort();
407
+ }
408
+ /**
409
+ * Read the complete account inventory through Sandbox offset pages.
410
+ *
411
+ * Sandbox returns only an array, so a short page is the terminal marker. A
412
+ * full page requires another request; stopping there would make recovery
413
+ * report a false absence. Duplicate ids or an inventory above the safety
414
+ * bound make completeness unknowable and therefore fail closed.
415
+ */
416
+ async function listAllSandboxChildren(client, signal) {
417
+ if (typeof client.list !== "function")
418
+ return undefined;
419
+ const children = [];
420
+ const seen = new Set();
421
+ let offset = 0;
422
+ while (true) {
423
+ signal?.throwIfAborted();
424
+ let page;
425
+ try {
426
+ const listed = await awaitWithSignal(client.list({
427
+ scope: "all",
428
+ limit: SANDBOX_LIST_PAGE_SIZE,
429
+ offset,
430
+ }), signal);
431
+ if (!Array.isArray(listed) || listed.length > SANDBOX_LIST_PAGE_SIZE) {
432
+ return undefined;
433
+ }
434
+ page = listed;
435
+ }
436
+ catch {
437
+ signal?.throwIfAborted();
438
+ return undefined;
439
+ }
440
+ for (const child of page) {
441
+ if (!child ||
442
+ typeof child !== "object" ||
443
+ safeIdentifier(child.id) === undefined ||
444
+ seen.has(child.id)) {
445
+ return undefined;
446
+ }
447
+ seen.add(child.id);
448
+ }
449
+ if (children.length + page.length > MAX_LIST_RESULTS)
450
+ return undefined;
451
+ children.push(...page);
452
+ if (page.length < SANDBOX_LIST_PAGE_SIZE)
453
+ return children;
454
+ if (offset > Number.MAX_SAFE_INTEGER - SANDBOX_LIST_PAGE_SIZE) {
455
+ return undefined;
456
+ }
457
+ offset += SANDBOX_LIST_PAGE_SIZE;
458
+ }
459
+ }
460
+ /**
461
+ * Read every account child that carries a fork marker this source produced.
462
+ *
463
+ * The scan is the shared front half of fork recovery and cleanup. It returns
464
+ * undefined when the inventory itself cannot be trusted, so both callers fail
465
+ * closed on the same condition.
466
+ */
467
+ async function listMarkedForkChildren(client, box, provider, key, signal) {
468
+ const children = await listAllSandboxChildren(client, signal);
469
+ if (children === undefined)
470
+ return undefined;
471
+ const marked = [];
472
+ for (const child of children) {
473
+ if (!child ||
474
+ typeof child !== "object" ||
475
+ safeIdentifier(child.id) === undefined) {
476
+ return undefined;
477
+ }
478
+ if (child.id === box.id)
479
+ continue;
480
+ const marker = forkMarkerFromMetadata(child.metadata, key);
481
+ if (!marker || !markerBelongsToSource(marker, provider, box.id))
482
+ continue;
483
+ marked.push({ child, marker });
484
+ }
485
+ return marked;
486
+ }
487
+ /**
488
+ * Read a fork ledger answer for a key that left no marked resource behind.
489
+ *
490
+ * `absent` is the settled answer: a decided operation with no inventory marker
491
+ * means the child was cleaned after creation, and the provider must not
492
+ * resurrect it from the ledger. Every other state is undecided for the caller.
493
+ */
494
+ export function lookupOutcomeFromSandbox(lookup, kind) {
495
+ if (!lookup || lookup.kind !== kind) {
496
+ return {
497
+ absent: false,
498
+ message: `Sandbox returned no ${kind} lookup`,
499
+ retryable: true,
500
+ };
501
+ }
502
+ if (lookup.outcome === "conflict") {
503
+ return {
504
+ absent: false,
505
+ message: "Sandbox found a conflicting operation without provider identity",
506
+ retryable: false,
507
+ };
508
+ }
509
+ if (lookup.outcome !== "not_found" &&
510
+ (lookup.outcome === "unknown" || lookup.state !== "succeeded")) {
511
+ return {
512
+ absent: false,
513
+ message: `Sandbox ${kind} operation is not decided`,
514
+ retryable: true,
515
+ };
516
+ }
517
+ return { absent: true };
518
+ }
519
+ export function isoDate(value) {
520
+ const date = value instanceof Date ? value : new Date(value);
521
+ if (!Number.isFinite(date.getTime()))
522
+ throw new Error("Sandbox returned an invalid workspace timestamp");
523
+ return date.toISOString();
524
+ }
525
+ function validDate(value) {
526
+ if (value === undefined)
527
+ return false;
528
+ const date = value instanceof Date ? value : new Date(value);
529
+ return Number.isFinite(date.getTime());
530
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-tangle",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
4
4
  "description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -87,6 +87,10 @@
87
87
  "dist/tangle-workspace-branching.js",
88
88
  "dist/tangle-confidential-attestation.d.ts",
89
89
  "dist/tangle-confidential-attestation.js",
90
+ "dist/tangle-workspace-markers.d.ts",
91
+ "dist/tangle-workspace-markers.js",
92
+ "dist/tangle-workspace-recovery.d.ts",
93
+ "dist/tangle-workspace-recovery.js",
90
94
  "README.md",
91
95
  "LICENSE"
92
96
  ],