@tangle-network/agent-provider-tangle 1.1.2 → 1.1.3
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/README.md +6 -1
- package/dist/tangle-workspace-branching.js +143 -47
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @tangle-network/agent-provider-tangle
|
|
2
2
|
|
|
3
3
|
Wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`.
|
|
4
|
-
The peer range is `>=0.34.6 <1.0.0`, and this package is developed and tested against 0.
|
|
4
|
+
The peer range is `>=0.34.6 <1.0.0`, and this package is developed and tested against 0.36.4.
|
|
5
5
|
The floor is 0.34.6 because exact interactive attachment needs the host receiver, and workspace branching needs keyed snapshots, durable restores, inventory recovery, and cleanup.
|
|
6
6
|
The provider fails closed when the configured backend or its catalog entry cannot be read.
|
|
7
7
|
Newer SDKs may also provide `getBackend()` as a lookup over the same catalog.
|
|
@@ -205,6 +205,11 @@ cannot prove the complete operation surface.
|
|
|
205
205
|
Recovery reads the account inventory through Sandbox offset pages of at most
|
|
206
206
|
1,000 sandboxes and continues until a short page proves the inventory is
|
|
207
207
|
complete.
|
|
208
|
+
If a live snapshot loses its marker tags, recovery reads the owner-scoped
|
|
209
|
+
operation record by key and binds its stored result to that live snapshot id.
|
|
210
|
+
The operation record alone never revives a deleted snapshot.
|
|
211
|
+
An older Sandbox deployment that cannot answer the owner-scoped lookup returns
|
|
212
|
+
`unknown` and does not create or mutate a checkpoint.
|
|
208
213
|
The adapter also reads checkpoints from the previous marker format while writing only the current bounded format.
|
|
209
214
|
Malformed, repeated, failed, or over-bound pages return `unknown` and do not
|
|
210
215
|
mutate a resource.
|
|
@@ -57,8 +57,8 @@ export function createTangleWorkspaceBranching(options) {
|
|
|
57
57
|
*
|
|
58
58
|
* The in-process record answers first; otherwise the Sandbox inventory and
|
|
59
59
|
* operation ledger rebuild it after a restart. `absent` means the key has no
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* record, which is the only state where a caller may go on to create one.
|
|
61
|
+
* `retired` means the durable operation settled but its snapshot is gone.
|
|
62
62
|
*/
|
|
63
63
|
const resolveCheckpoint = async (request, signal) => {
|
|
64
64
|
const local = checkpoints.get(request.idempotencyKey);
|
|
@@ -70,30 +70,20 @@ export function createTangleWorkspaceBranching(options) {
|
|
|
70
70
|
existingRequestDigest: local.request.requestDigest,
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
-
const recovered = await
|
|
74
|
-
if (recovered ===
|
|
73
|
+
const recovered = await reconcileCheckpoint(box, provider, request, signal);
|
|
74
|
+
if (recovered.state === "undecided") {
|
|
75
75
|
return {
|
|
76
76
|
state: "undecided",
|
|
77
|
-
message:
|
|
77
|
+
message: recovered.reason === "inventory_unavailable"
|
|
78
|
+
? "Sandbox checkpoint inventory is unavailable"
|
|
79
|
+
: "Sandbox checkpoint metadata is invalid",
|
|
78
80
|
};
|
|
79
81
|
}
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
state: "conflict",
|
|
85
|
-
existingRequestDigest: recovered.marker.requestDigest,
|
|
86
|
-
};
|
|
82
|
+
if (recovered.state === "found") {
|
|
83
|
+
checkpoints.set(request.idempotencyKey, recovered.record);
|
|
84
|
+
return { state: "known", record: recovered.record };
|
|
87
85
|
}
|
|
88
|
-
|
|
89
|
-
if (!record) {
|
|
90
|
-
return {
|
|
91
|
-
state: "undecided",
|
|
92
|
-
message: "Sandbox checkpoint metadata is invalid",
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
checkpoints.set(request.idempotencyKey, record);
|
|
96
|
-
return { state: "known", record };
|
|
86
|
+
return recovered;
|
|
97
87
|
};
|
|
98
88
|
/** The fork equivalent of {@link resolveCheckpoint}. */
|
|
99
89
|
const resolveFork = async (request, signal) => {
|
|
@@ -153,17 +143,21 @@ export function createTangleWorkspaceBranching(options) {
|
|
|
153
143
|
if (known.state === "undecided") {
|
|
154
144
|
return checkpointUnknown(request, `${known.message}; retry after reconciliation`, true);
|
|
155
145
|
}
|
|
146
|
+
if (known.state === "retired") {
|
|
147
|
+
return checkpointUnknown(request, "Sandbox checkpoint operation is settled but its snapshot is no longer present; the idempotency key cannot be reused", false);
|
|
148
|
+
}
|
|
156
149
|
if (known.state === "known") {
|
|
157
150
|
return checkpointSuccess(request, known.record.checkpoint, "replayed");
|
|
158
151
|
}
|
|
159
152
|
const tags = checkpointMarkerTags(request);
|
|
160
153
|
let result;
|
|
161
154
|
try {
|
|
155
|
+
operation?.signal?.throwIfAborted();
|
|
162
156
|
result = await awaitWithSignal(box.snapshot?.({ tags, idempotencyKey: request.idempotencyKey }), operation?.signal);
|
|
163
157
|
}
|
|
164
158
|
catch (error) {
|
|
165
159
|
operation?.signal?.throwIfAborted();
|
|
166
|
-
const conflict = await checkpointConflictFromRemote(box, request, operation?.signal);
|
|
160
|
+
const conflict = await checkpointConflictFromRemote(box, provider, request, operation?.signal);
|
|
167
161
|
return (conflict ??
|
|
168
162
|
checkpointUnknown(request, `Sandbox checkpoint outcome is unresolved: ${safeError(error)}`, true));
|
|
169
163
|
}
|
|
@@ -180,6 +174,26 @@ export function createTangleWorkspaceBranching(options) {
|
|
|
180
174
|
if (resultMarker && resultMarker.requestDigest !== request.requestDigest) {
|
|
181
175
|
return checkpointConflict(request, resultMarker.requestDigest);
|
|
182
176
|
}
|
|
177
|
+
if (resultMarker &&
|
|
178
|
+
!checkpointMarkerBelongsToSource(resultMarker, provider, box.id)) {
|
|
179
|
+
return checkpointUnknown(request, "Sandbox checkpoint acknowledgement belongs to another source", false);
|
|
180
|
+
}
|
|
181
|
+
if (result.idempotency.outcome === "replayed") {
|
|
182
|
+
const recovered = await reconcileCheckpoint(box, provider, request, operation?.signal);
|
|
183
|
+
if (recovered.state === "retired" || recovered.state === "absent") {
|
|
184
|
+
return checkpointUnknown(request, "Sandbox replay acknowledged a checkpoint that is no longer present", false);
|
|
185
|
+
}
|
|
186
|
+
if (recovered.state === "undecided") {
|
|
187
|
+
return checkpointUnknown(request, recovered.reason === "metadata_invalid"
|
|
188
|
+
? "Sandbox replay returned invalid checkpoint metadata"
|
|
189
|
+
: "Sandbox replay could not be reconciled with live checkpoint inventory", true);
|
|
190
|
+
}
|
|
191
|
+
if (recovered.state === "conflict") {
|
|
192
|
+
return checkpointConflict(request, recovered.existingRequestDigest);
|
|
193
|
+
}
|
|
194
|
+
checkpoints.set(request.idempotencyKey, recovered.record);
|
|
195
|
+
return checkpointSuccess(request, recovered.record.checkpoint, "replayed");
|
|
196
|
+
}
|
|
183
197
|
if (!resultMarker) {
|
|
184
198
|
return checkpointUnknown(request, "Sandbox checkpoint acknowledgement omitted its provider recovery marker", true);
|
|
185
199
|
}
|
|
@@ -199,20 +213,13 @@ export function createTangleWorkspaceBranching(options) {
|
|
|
199
213
|
if (known.state === "undecided") {
|
|
200
214
|
return checkpointLookupUnknown(request, known.message, true);
|
|
201
215
|
}
|
|
216
|
+
if (known.state === "retired") {
|
|
217
|
+
return checkpointNotFound(request);
|
|
218
|
+
}
|
|
202
219
|
if (known.state === "known") {
|
|
203
220
|
return checkpointFound(request, known.record.checkpoint);
|
|
204
221
|
}
|
|
205
|
-
|
|
206
|
-
const lookup = await awaitWithSignal(box.getSnapshotOperation?.(request.idempotencyKey, { tags: [] }), operation?.signal);
|
|
207
|
-
const settled = lookupOutcomeFromSandbox(lookup, "checkpoint");
|
|
208
|
-
return settled.absent
|
|
209
|
-
? checkpointNotFound(request)
|
|
210
|
-
: checkpointLookupUnknown(request, settled.message, settled.retryable);
|
|
211
|
-
}
|
|
212
|
-
catch (error) {
|
|
213
|
-
operation?.signal?.throwIfAborted();
|
|
214
|
-
return checkpointLookupUnknown(request, `Sandbox checkpoint lookup failed: ${safeError(error)}`, true);
|
|
215
|
-
}
|
|
222
|
+
return checkpointNotFound(request);
|
|
216
223
|
};
|
|
217
224
|
const deleteCheckpoint = async (input, operation) => {
|
|
218
225
|
const request = WorkspaceCleanupRequestSchema.parse(input);
|
|
@@ -731,6 +738,11 @@ function validSnapshotOperationResult(value) {
|
|
|
731
738
|
safeIdentifier(value.snapshotId) !== undefined &&
|
|
732
739
|
validOperationDate(value.createdAt));
|
|
733
740
|
}
|
|
741
|
+
function validTaggedSnapshotOperationResult(value) {
|
|
742
|
+
return (validSnapshotOperationResult(value) &&
|
|
743
|
+
Array.isArray(value.tags) &&
|
|
744
|
+
value.tags.every((tag) => safeString(tag) !== undefined));
|
|
745
|
+
}
|
|
734
746
|
function validForkOperationChildResult(value) {
|
|
735
747
|
return (validOperationRecord(value) &&
|
|
736
748
|
safeIdentifier(value.sandboxId ?? value.id) !== undefined &&
|
|
@@ -796,6 +808,7 @@ function forkMarkerMetadata(request, materialization = "snapshot") {
|
|
|
796
808
|
* until the ledger reports the operation succeeded.
|
|
797
809
|
*/
|
|
798
810
|
async function checkpointOperationLookup(box, marker, signal) {
|
|
811
|
+
signal?.throwIfAborted();
|
|
799
812
|
const lookup = await awaitWithSignal(box.getSnapshotOperation?.(marker.idempotencyKey, {
|
|
800
813
|
tags: marker.legacy
|
|
801
814
|
? legacyCheckpointMarkerTags(marker.request)
|
|
@@ -803,6 +816,11 @@ async function checkpointOperationLookup(box, marker, signal) {
|
|
|
803
816
|
}), signal);
|
|
804
817
|
return lookup;
|
|
805
818
|
}
|
|
819
|
+
/** Read the durable record by its owner-scoped key when no request body remains. */
|
|
820
|
+
async function checkpointOperationLookupByKey(box, idempotencyKey, signal) {
|
|
821
|
+
signal?.throwIfAborted();
|
|
822
|
+
return await awaitWithSignal(box.getSnapshotOperation?.(idempotencyKey), signal);
|
|
823
|
+
}
|
|
806
824
|
async function checkpointOperationSucceeded(box, marker, signal) {
|
|
807
825
|
const lookup = await checkpointOperationLookup(box, marker, signal);
|
|
808
826
|
return (lookup?.outcome === "found" &&
|
|
@@ -850,9 +868,10 @@ function markerTags(kind, idempotencyKey, requestDigest, marker) {
|
|
|
850
868
|
return tag;
|
|
851
869
|
});
|
|
852
870
|
}
|
|
853
|
-
async function findCheckpointByKey(box, key, signal) {
|
|
871
|
+
async function findCheckpointByKey(box, provider, key, signal) {
|
|
854
872
|
let snapshots;
|
|
855
873
|
try {
|
|
874
|
+
signal?.throwIfAborted();
|
|
856
875
|
const listed = await awaitWithSignal(box.listSnapshots?.(), signal);
|
|
857
876
|
if (!Array.isArray(listed))
|
|
858
877
|
return undefined;
|
|
@@ -865,13 +884,21 @@ async function findCheckpointByKey(box, key, signal) {
|
|
|
865
884
|
if (!Array.isArray(snapshots) || snapshots.length > MAX_LIST_RESULTS) {
|
|
866
885
|
return undefined;
|
|
867
886
|
}
|
|
887
|
+
const snapshotIds = new Set();
|
|
888
|
+
let found;
|
|
868
889
|
let unresolved = false;
|
|
869
890
|
for (const snapshot of snapshots) {
|
|
870
891
|
if (!validSnapshotInfo(snapshot, box.id))
|
|
871
892
|
return undefined;
|
|
893
|
+
if (snapshotIds.has(snapshot.snapshotId))
|
|
894
|
+
return undefined;
|
|
895
|
+
snapshotIds.add(snapshot.snapshotId);
|
|
872
896
|
const marker = checkpointMarkerFromTags(snapshot.tags, key);
|
|
873
897
|
if (!marker)
|
|
874
898
|
continue;
|
|
899
|
+
if (!checkpointMarkerBelongsToSource(marker, provider, box.id)) {
|
|
900
|
+
return undefined;
|
|
901
|
+
}
|
|
875
902
|
try {
|
|
876
903
|
const lookup = await checkpointOperationLookup(box, marker, signal);
|
|
877
904
|
if (lookup?.outcome === "found" &&
|
|
@@ -880,7 +907,10 @@ async function findCheckpointByKey(box, key, signal) {
|
|
|
880
907
|
const authoritative = snapshotFromOperationResult(snapshot, lookup);
|
|
881
908
|
if (authoritative === undefined)
|
|
882
909
|
return undefined;
|
|
883
|
-
|
|
910
|
+
if (found !== undefined)
|
|
911
|
+
return undefined;
|
|
912
|
+
found = { state: "found", snapshot: authoritative, marker };
|
|
913
|
+
continue;
|
|
884
914
|
}
|
|
885
915
|
unresolved = true;
|
|
886
916
|
}
|
|
@@ -889,7 +919,69 @@ async function findCheckpointByKey(box, key, signal) {
|
|
|
889
919
|
return undefined;
|
|
890
920
|
}
|
|
891
921
|
}
|
|
892
|
-
|
|
922
|
+
if (found !== undefined)
|
|
923
|
+
return found;
|
|
924
|
+
if (unresolved)
|
|
925
|
+
return undefined;
|
|
926
|
+
// Some storage backends retain the snapshot but omit caller tags from a
|
|
927
|
+
// later inventory read. The owner-scoped operation record retains the exact
|
|
928
|
+
// acknowledgement, including those tags. Bind that record to a currently
|
|
929
|
+
// live snapshot id before recovering it; neither record is sufficient alone.
|
|
930
|
+
let lookup;
|
|
931
|
+
try {
|
|
932
|
+
lookup = await checkpointOperationLookupByKey(box, key, signal);
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
signal?.throwIfAborted();
|
|
936
|
+
return undefined;
|
|
937
|
+
}
|
|
938
|
+
if (lookup?.outcome === "not_found" && lookup.kind === "checkpoint") {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
if (lookup?.outcome !== "found" ||
|
|
942
|
+
lookup.kind !== "checkpoint" ||
|
|
943
|
+
lookup.state !== "succeeded") {
|
|
944
|
+
return undefined;
|
|
945
|
+
}
|
|
946
|
+
if (snapshots.length === 0)
|
|
947
|
+
return { state: "retired" };
|
|
948
|
+
if (!validTaggedSnapshotOperationResult(lookup.result))
|
|
949
|
+
return undefined;
|
|
950
|
+
const live = snapshots.filter((snapshot) => snapshot.snapshotId === lookup.result?.snapshotId);
|
|
951
|
+
if (live.length === 0)
|
|
952
|
+
return { state: "retired" };
|
|
953
|
+
if (live.length !== 1)
|
|
954
|
+
return undefined;
|
|
955
|
+
const marker = checkpointMarkerFromTags(lookup.result.tags, key);
|
|
956
|
+
if (!marker ||
|
|
957
|
+
!checkpointMarkerBelongsToSource(marker, provider, box.id)) {
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
const authoritative = snapshotFromOperationResult(live[0], lookup);
|
|
961
|
+
return authoritative === undefined
|
|
962
|
+
? undefined
|
|
963
|
+
: { state: "found", snapshot: authoritative, marker };
|
|
964
|
+
}
|
|
965
|
+
/** Normalize one remote checkpoint recovery attempt for every caller. */
|
|
966
|
+
async function reconcileCheckpoint(box, provider, request, signal) {
|
|
967
|
+
const recovered = await findCheckpointByKey(box, provider, request.idempotencyKey, signal);
|
|
968
|
+
if (recovered === undefined) {
|
|
969
|
+
return { state: "undecided", reason: "inventory_unavailable" };
|
|
970
|
+
}
|
|
971
|
+
if (recovered === null)
|
|
972
|
+
return { state: "absent" };
|
|
973
|
+
if (recovered.state === "retired")
|
|
974
|
+
return recovered;
|
|
975
|
+
if (recovered.marker.requestDigest !== request.requestDigest) {
|
|
976
|
+
return {
|
|
977
|
+
state: "conflict",
|
|
978
|
+
existingRequestDigest: recovered.marker.requestDigest,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
const record = checkpointRecordFromSnapshot(recovered.marker.request, recovered.snapshot);
|
|
982
|
+
return record === undefined
|
|
983
|
+
? { state: "undecided", reason: "metadata_invalid" }
|
|
984
|
+
: { state: "found", record };
|
|
893
985
|
}
|
|
894
986
|
/**
|
|
895
987
|
* Prefer the durable operation result over inventory metadata.
|
|
@@ -1177,6 +1269,10 @@ function markerBelongsToSource(marker, provider, sourceEnvironmentId) {
|
|
|
1177
1269
|
return (marker.request.checkpoint.provider === provider &&
|
|
1178
1270
|
marker.request.checkpoint.source.environmentId === sourceEnvironmentId);
|
|
1179
1271
|
}
|
|
1272
|
+
function checkpointMarkerBelongsToSource(marker, provider, sourceEnvironmentId) {
|
|
1273
|
+
return (marker.request.source.provider === provider &&
|
|
1274
|
+
marker.request.source.environmentId === sourceEnvironmentId);
|
|
1275
|
+
}
|
|
1180
1276
|
function checkpointMarkerFromTags(tags, key) {
|
|
1181
1277
|
if (!Array.isArray(tags) ||
|
|
1182
1278
|
tags.length > MAX_MARKER_CHUNKS + 3 ||
|
|
@@ -1324,19 +1420,19 @@ function forkMarkerFromMetadata(metadata, key) {
|
|
|
1324
1420
|
* digest covers the Sandbox request body, not this interface's identity, so it
|
|
1325
1421
|
* is never presented as an interface digest.
|
|
1326
1422
|
*/
|
|
1327
|
-
async function checkpointConflictFromRemote(box, request, signal) {
|
|
1423
|
+
async function checkpointConflictFromRemote(box, provider, request, signal) {
|
|
1328
1424
|
signal?.throwIfAborted();
|
|
1329
|
-
const recovered = await
|
|
1425
|
+
const recovered = await reconcileCheckpoint(box, provider, request, signal);
|
|
1330
1426
|
signal?.throwIfAborted();
|
|
1331
|
-
if (
|
|
1332
|
-
return
|
|
1333
|
-
if (recovered.marker.requestDigest !== request.requestDigest) {
|
|
1334
|
-
return checkpointConflict(request, recovered.marker.requestDigest);
|
|
1427
|
+
if (recovered.state === "retired") {
|
|
1428
|
+
return checkpointUnknown(request, "Sandbox checkpoint operation is settled but its snapshot is no longer present; the idempotency key cannot be reused", false);
|
|
1335
1429
|
}
|
|
1336
|
-
|
|
1337
|
-
|
|
1430
|
+
if (recovered.state === "conflict") {
|
|
1431
|
+
return checkpointConflict(request, recovered.existingRequestDigest);
|
|
1432
|
+
}
|
|
1433
|
+
return recovered.state !== "found"
|
|
1338
1434
|
? undefined
|
|
1339
|
-
: checkpointSuccess(request, record.checkpoint, "replayed");
|
|
1435
|
+
: checkpointSuccess(request, recovered.record.checkpoint, "replayed");
|
|
1340
1436
|
}
|
|
1341
1437
|
/** The fork equivalent of {@link checkpointConflictFromRemote}. */
|
|
1342
1438
|
async function forkConflictFromRemote(client, box, provider, request, verifier, signal) {
|
|
@@ -1355,10 +1451,10 @@ async function forkConflictFromRemote(client, box, provider, request, verifier,
|
|
|
1355
1451
|
: forkSuccess(request, environment, "replayed");
|
|
1356
1452
|
}
|
|
1357
1453
|
/**
|
|
1358
|
-
* Read a ledger answer for a key that left no marked resource behind.
|
|
1454
|
+
* Read a fork ledger answer for a key that left no marked resource behind.
|
|
1359
1455
|
*
|
|
1360
1456
|
* `absent` is the settled answer: a decided operation with no inventory marker
|
|
1361
|
-
* means the
|
|
1457
|
+
* means the child was cleaned after creation, and the provider must not
|
|
1362
1458
|
* resurrect it from the ledger. Every other state is undecided for the caller.
|
|
1363
1459
|
*/
|
|
1364
1460
|
function lookupOutcomeFromSandbox(lookup, kind) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-provider-tangle",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -102,13 +102,13 @@
|
|
|
102
102
|
}
|
|
103
103
|
},
|
|
104
104
|
"devDependencies": {
|
|
105
|
-
"@tangle-network/agent-eval": "0.
|
|
106
|
-
"@tangle-network/agent-runtime": "0.
|
|
107
|
-
"@tangle-network/sandbox": "0.
|
|
105
|
+
"@tangle-network/agent-eval": "0.173.0",
|
|
106
|
+
"@tangle-network/agent-runtime": "0.190.0",
|
|
107
|
+
"@tangle-network/sandbox": "0.36.4",
|
|
108
108
|
"@types/node": "26.4.0",
|
|
109
109
|
"typescript": "7.0.2",
|
|
110
110
|
"vitest": "4.1.11",
|
|
111
|
-
"@tangle-network/agent-provider-testkit": "0.9.
|
|
111
|
+
"@tangle-network/agent-provider-testkit": "0.9.1"
|
|
112
112
|
},
|
|
113
113
|
"scripts": {
|
|
114
114
|
"build": "tsc -p tsconfig.json",
|