@golem-sdk/golem-js
Version:
NodeJS and WebBrowser SDK for building apps running on Golem Network
1,287 lines (1,260 loc) • 302 kB
JavaScript
'use strict';
var debugLogger = require('debug');
var YaTsClient = require('ya-ts-client');
var uuid = require('uuid');
var semverSatisfies = require('semver/functions/satisfies.js');
var semverCoerce = require('semver/functions/coerce.js');
var rxjs = require('rxjs');
var EventSource = require('eventsource');
var eventemitter3 = require('eventemitter3');
var AsyncLock = require('async-lock');
var Decimal = require('decimal.js-light');
var path = require('path');
var fs = require('fs');
var spawn = require('cross-spawn');
var flexbuffers_js = require('flatbuffers/js/flexbuffers.js');
var jsSha3 = require('js-sha3');
var NodeWebSocket = require('ws');
var net = require('net');
var buffer = require('buffer');
var retry = require('async-retry');
var ipNum = require('ip-num');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var YaTsClient__namespace = /*#__PURE__*/_interopNamespaceDefault(YaTsClient);
var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
var jsSha3__namespace = /*#__PURE__*/_interopNamespaceDefault(jsSha3);
/**
* @param time
* @param inMs
* @ignore
*/
const sleep = (time, inMs = false) => new Promise((resolve) => setTimeout(resolve, time * (inMs ? 1 : 1000)));
/**
* Base class for all errors directly thrown by Golem SDK.
*/
class GolemError extends Error {
constructor(message,
/**
* The previous error, if any, that led to this error.
*/
previous) {
super(message);
this.previous = previous;
}
}
/**
* User-caused errors in the Golem SDK containing logic errors.
* @example you cannot create an activity for an agreement that already expired
*/
class GolemUserError extends GolemError {
}
/**
* Represents errors related to the user choosing to abort or stop running activities.
* @example CTRL+C abort error
*/
class GolemAbortError extends GolemUserError {
}
/**
* Represents configuration errors.
* @example Api key not defined
*/
class GolemConfigError extends GolemUserError {
}
/**
* Represents errors when the SDK encountered an internal error that wasn't handled correctly.
* @example JSON.parse(undefined) -> Error: Unexpected token u in JSON at position 0
*/
class GolemInternalError extends GolemError {
}
/**
* Represents errors resulting from yagna’s errors or provider failure
* @examples:
* - yagna results with a HTTP 500-error
* - the provider failed to deploy the activity - permission denied when creating the activity on the provider system itself
*/
class GolemPlatformError extends GolemError {
}
/**
* SDK timeout errors
* @examples:
* - Not receiving any offers within the configured time.
* - The activity not starting within the configured time.
* - The request (task) timing out (started on an activity but didn't finish on time).
* - The request start timing out (the task didn't start within the configured amount of time).
*/
class GolemTimeoutError extends GolemError {
}
/**
* Module specific errors - Market, Work, Payment.
* Each of the major modules will have its own domain specific root error type,
* additionally containing an error code specific to a given subdomain
*/
class GolemModuleError extends GolemError {
constructor(message, code, previous) {
super(message, previous);
this.code = code;
}
}
/**
* @ignore
*/
const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined";
const isNode = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
/**
* @ignore
*/
const isWebWorker = typeof self === "object" && self.constructor && self.constructor.name === "DedicatedWorkerGlobalScope";
/**
* @ignore
*/
function checkAndThrowUnsupportedInBrowserError(feature) {
if (isBrowser)
throw new GolemInternalError(`Feature ${feature} is unsupported in the browser.`);
}
function nullLogger() {
const nullFunc = () => {
// Do nothing.
};
return {
child: () => nullLogger(),
debug: nullFunc,
info: nullFunc,
warn: nullFunc,
error: nullFunc,
};
}
function getNamespace(namespace, disablePrefix) {
if (disablePrefix) {
return namespace;
}
else {
return namespace.startsWith("golem-js:") ? namespace : `golem-js:${namespace}`;
}
}
/**
* Creates a logger that uses the debug library. This logger is used by default by all entities in the SDK.
*
* If the namespace is not prefixed with `golem-js:`, it will be prefixed automatically - this can be controlled by `disableAutoPrefix` options.
*/
function defaultLogger(namespace, opts = {
disableAutoPrefix: false,
}) {
const namespaceWithBase = getNamespace(namespace, opts.disableAutoPrefix);
const logger = debugLogger(namespaceWithBase);
function log(level, msg, ctx) {
if (ctx) {
logger(`[${level}] ${msg} %o`, ctx);
}
else {
logger(`[${level}] ${msg}`);
}
}
function debug(msg, ctx) {
log("DEBUG", msg, ctx);
}
function info(msg, ctx) {
log("INFO", msg, ctx);
}
function warn(msg, ctx) {
log("WARN", msg, ctx);
}
function error(msg, ctx) {
log("ERROR", msg, ctx);
}
return {
child: (childNamespace) => defaultLogger(`${namespaceWithBase}:${childNamespace}`, opts),
info,
error,
warn,
debug,
};
}
function getYagnaApiUrl() {
return (isNode ? process === null || process === void 0 ? void 0 : process.env.YAGNA_API_URL : "") || "http://127.0.0.1:7465";
}
function getYagnaAppKey() {
var _a;
return isNode ? ((_a = process === null || process === void 0 ? void 0 : process.env.YAGNA_APPKEY) !== null && _a !== void 0 ? _a : "") : "";
}
function getYagnaSubnet() {
var _a;
return isNode ? ((_a = process === null || process === void 0 ? void 0 : process.env.YAGNA_SUBNET) !== null && _a !== void 0 ? _a : "public") : "public";
}
function getRepoUrl() {
var _a;
return isNode
? ((_a = process === null || process === void 0 ? void 0 : process.env.GOLEM_REGISTRY_URL) !== null && _a !== void 0 ? _a : "https://registry.golem.network")
: "https://registry.golem.network";
}
function getPaymentNetwork() {
var _a;
return isNode ? ((_a = process.env.PAYMENT_NETWORK) !== null && _a !== void 0 ? _a : "holesky") : "holesky";
}
function isDevMode() {
return isNode ? (process === null || process === void 0 ? void 0 : process.env.GOLEM_DEV_MODE) === "true" : false;
}
var env = /*#__PURE__*/Object.freeze({
__proto__: null,
getPaymentNetwork: getPaymentNetwork,
getRepoUrl: getRepoUrl,
getYagnaApiUrl: getYagnaApiUrl,
getYagnaAppKey: getYagnaAppKey,
getYagnaSubnet: getYagnaSubnet,
isDevMode: isDevMode
});
/**
* Utility function that helps to block the execution until a condition is met (check returns true) or the timeout happens.
*
* @param {function} check - The function checking if the condition is met.
* @param {Object} [opts] - Options controlling the timeout and check interval in seconds.
* @param {AbortSignal} [opts.abortSignal] - AbortSignal to respect when waiting for the condition to be met
* @param {number} [opts.intervalSeconds=1] - The interval between condition checks in seconds.
*
* @return {Promise<void>} - Resolves when the condition is met or rejects with a timeout error if it wasn't met on time.
*/
function waitFor(check, opts) {
var _a;
const intervalSeconds = (_a = opts === null || opts === void 0 ? void 0 : opts.intervalSeconds) !== null && _a !== void 0 ? _a : 1;
let verifyInterval;
const verify = new Promise((resolve, reject) => {
verifyInterval = setInterval(async () => {
var _a;
if ((_a = opts === null || opts === void 0 ? void 0 : opts.abortSignal) === null || _a === void 0 ? void 0 : _a.aborted) {
reject(new GolemAbortError("Waiting for a condition has been aborted", opts.abortSignal.reason));
}
if (await check()) {
resolve();
}
}, intervalSeconds * 1000);
});
return verify.finally(() => {
clearInterval(verifyInterval);
});
}
/**
* Simple utility that allows you to wait n-seconds and then call the provided function
*/
function waitAndCall(fn, waitSeconds) {
return new Promise((resolve, reject) => {
setTimeout(async () => {
try {
const val = await fn();
resolve(val);
}
catch (err) {
reject(err);
}
}, waitSeconds * 1000);
});
}
class EventReader {
constructor(logger) {
this.logger = logger;
}
async pollToSubject(generator, subject) {
for await (const value of generator) {
subject.next(value);
}
subject.complete();
}
createReader(eventType, eventsFetcher) {
let isFinished = false;
let keepReading = true;
let currentPoll = null;
let lastTimestamp = new Date().toISOString();
const logger = this.logger;
return {
eventType,
isFinished,
pollValues: async function* () {
while (keepReading) {
try {
currentPoll = eventsFetcher(lastTimestamp);
const events = await currentPoll;
logger.debug("Polled events from Yagna", {
eventType,
count: events.length,
lastEventTimestamp: lastTimestamp,
});
for (const event of events) {
yield event;
lastTimestamp = event.eventDate;
}
}
catch (error) {
if (typeof error === "object" && error.name === "CancelError") {
logger.debug("Polling was cancelled", { eventType });
continue;
}
logger.error("Error fetching events from Yagna", { eventType, error });
}
}
logger.debug("Stopped reading events", { eventType });
isFinished = true;
},
cancel: async function () {
keepReading = false;
if (currentPoll) {
currentPoll.cancel();
}
await waitFor(() => isFinished, { intervalSeconds: 0 });
logger.debug("Cancelled reading the events", { eventType });
},
};
}
}
const MIN_SUPPORTED_YAGNA = "0.15.2";
/**
* Utility class that groups various Yagna APIs under a single wrapper
*
* This class has the following responsibilities:
*
* - selectively exposes services from ya-ts-client in a more user-friendly manner
* - implements an event reader that collects events from Yagna endpoints and allows subscribing to them as Observables
* for agreements, debit notes and invoices. These observables emit ya-ts-client types on outputs
*
* End users of the SDK should not use this class and make use of {@link golem-network/golem-network.GolemNetwork} instead. This class is designed for
* SDK developers to use.
*/
class YagnaApi {
constructor(options) {
this.debitNoteEvents$ = new rxjs.Subject();
this.debitNoteEventsPoll = null;
this.invoiceEvents$ = new rxjs.Subject();
this.invoiceEventPoll = null;
this.agreementEvents$ = new rxjs.Subject();
this.agreementEventsPoll = null;
const apiKey = (options === null || options === void 0 ? void 0 : options.apiKey) || getYagnaAppKey();
this.basePath = (options === null || options === void 0 ? void 0 : options.basePath) || getYagnaApiUrl();
const yagnaOptions = {
apiKey: apiKey,
basePath: this.basePath,
};
if (!yagnaOptions.apiKey) {
throw new GolemConfigError("Yagna API key not defined");
}
const commonHeaders = {
Authorization: `Bearer ${apiKey}`,
};
const marketClient = new YaTsClient__namespace.MarketApi.Client({
BASE: `${this.basePath}/market-api/v1`,
HEADERS: commonHeaders,
});
this.market = marketClient.requestor;
const paymentClient = new YaTsClient__namespace.PaymentApi.Client({
BASE: `${this.basePath}/payment-api/v1`,
HEADERS: commonHeaders,
});
this.payment = paymentClient.requestor;
const activityApiClient = new YaTsClient__namespace.ActivityApi.Client({
BASE: `${this.basePath}/activity-api/v1`,
HEADERS: commonHeaders,
});
this.activity = {
control: activityApiClient.requestorControl,
state: activityApiClient.requestorState,
exec: {
observeBatchExecResults: (activityId, batchId) => {
return new rxjs.Observable((observer) => {
const eventSource = new EventSource(`${this.basePath}/activity-api/v1/activity/${activityId}/exec/${batchId}`, {
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${apiKey}`,
},
});
eventSource.addEventListener("runtime", (event) => observer.next(JSON.parse(event.data)));
eventSource.addEventListener("error", (error) => observer.error(error));
return () => eventSource.close();
});
},
},
};
const netClient = new YaTsClient__namespace.NetApi.Client({
BASE: `${this.basePath}/net-api/v1`,
HEADERS: commonHeaders,
});
this.net = netClient.requestor;
const gsbClient = new YaTsClient__namespace.GsbApi.Client({
BASE: `${this.basePath}/gsb-api/v1`,
HEADERS: commonHeaders,
});
this.gsb = gsbClient.requestor;
this.logger = (options === null || options === void 0 ? void 0 : options.logger) ? options.logger.child("yagna") : defaultLogger("yagna");
const identityClient = new YaTsClient__namespace.IdentityApi.Client({
BASE: this.basePath,
HEADERS: commonHeaders,
});
this.identity = identityClient.default;
const versionClient = new YaTsClient__namespace.VersionApi.Client({
BASE: this.basePath,
});
this.version = versionClient.default;
this.yagnaOptions = yagnaOptions;
this.appSessionId = uuid.v4();
this.reader = new EventReader(this.logger);
}
/**
* Effectively starts the Yagna API client including subscribing to events exposed via rxjs subjects
*/
async connect() {
this.logger.debug("Connecting to Yagna");
const version = await this.assertSupportedVersion();
const identity = await this.identity.getIdentity();
this.startPollingEvents();
this.logger.info("Connected to Yagna", { version, identity: identity.identity });
return identity;
}
/**
* Terminates the Yagna API related activities
*/
async disconnect() {
this.logger.debug("Disconnecting from Yagna");
await this.stopPollingEvents();
this.logger.info("Disconnected from Yagna");
}
async getVersion() {
try {
const res = await this.version.getVersion();
return res.current.version;
}
catch (err) {
throw new GolemPlatformError(`Failed to establish yagna version due to: ${err}`, err);
}
}
startPollingEvents() {
this.logger.debug("Starting to poll for events from Yagna", { appSessionId: this.appSessionId });
const pollIntervalSec = 5;
const maxEvents = 100;
this.agreementEventsPoll = this.reader.createReader("AgreementEvents", (lastEventTimestamp) => this.market.collectAgreementEvents(pollIntervalSec, lastEventTimestamp, maxEvents, this.appSessionId));
this.debitNoteEventsPoll = this.reader.createReader("DebitNoteEvents", (lastEventTimestamp) => {
return this.payment.getDebitNoteEvents(pollIntervalSec, lastEventTimestamp, maxEvents, this.appSessionId);
});
this.invoiceEventPoll = this.reader.createReader("InvoiceEvents", (lastEventTimestamp) => this.payment.getInvoiceEvents(pollIntervalSec, lastEventTimestamp, maxEvents, this.appSessionId));
// Run the readers and don't block execution
this.reader
.pollToSubject(this.agreementEventsPoll.pollValues(), this.agreementEvents$)
.then(() => this.logger.debug("Finished polling agreement events from Yagna"))
.catch((err) => this.logger.error("Error while polling agreement events from Yagna", err));
this.reader
.pollToSubject(this.debitNoteEventsPoll.pollValues(), this.debitNoteEvents$)
.then(() => this.logger.debug("Finished polling debit note events from Yagna"))
.catch((err) => this.logger.error("Error while polling debit note events from Yagna", err));
this.reader
.pollToSubject(this.invoiceEventPoll.pollValues(), this.invoiceEvents$)
.then(() => this.logger.debug("Finished polling invoice events from Yagna"))
.catch((err) => this.logger.error("Error while polling invoice events from Yagna", err));
}
async stopPollingEvents() {
this.logger.debug("Stopping polling events from Yagna");
const promises = [];
if (this.invoiceEventPoll) {
promises.push(this.invoiceEventPoll.cancel());
}
if (this.debitNoteEventsPoll) {
promises.push(this.debitNoteEventsPoll.cancel());
}
if (this.agreementEventsPoll) {
promises.push(this.agreementEventsPoll.cancel());
}
await Promise.allSettled(promises);
this.logger.debug("Stopped polling events from Yagna");
}
async assertSupportedVersion() {
const version = await this.getVersion();
const normVersion = semverCoerce(version);
this.logger.debug("Checking Yagna version support", {
userInstalled: normVersion === null || normVersion === void 0 ? void 0 : normVersion.raw,
minSupported: MIN_SUPPORTED_YAGNA,
});
if (!normVersion) {
throw new GolemPlatformError(`Unreadable yagna version '${version}'. Can't proceed without checking yagna version support status.`);
}
if (!semverSatisfies(normVersion, `>=${MIN_SUPPORTED_YAGNA}`)) {
throw new GolemPlatformError(`You run yagna in version ${version} and the minimal version supported by the SDK is ${MIN_SUPPORTED_YAGNA}. ` +
`Please consult the golem-js README to find matching SDK version or upgrade your yagna installation.`);
}
return normVersion.version;
}
}
/**
* If provided an AbortSignal, returns it.
* If provided a number, returns an AbortSignal that will be aborted after the specified number of milliseconds.
* If provided undefined, returns an AbortSignal that will never be aborted.
*/
function createAbortSignalFromTimeout(timeoutOrSignal) {
if (timeoutOrSignal instanceof AbortSignal) {
return timeoutOrSignal;
}
if (typeof timeoutOrSignal === "number") {
return AbortSignal.timeout(timeoutOrSignal);
}
return new AbortController().signal;
}
/**
* Combine multiple AbortSignals into a single signal that will be aborted if any
* of the input signals are aborted. If any of the input signals are already aborted,
* the returned signal will be aborted immediately.
*
* Polyfill for AbortSignal.any(), since it's only available starting in Node 20
* https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static
*
* The function returns a signal and a cleanup function that allows you
* to remove listeners when they are no longer needed.
*/
function anyAbortSignal(...signals) {
const controller = new AbortController();
const onAbort = (ev) => {
if (controller.signal.aborted)
return;
const reason = ev.target.reason;
controller.abort(reason);
};
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
break;
}
signal.addEventListener("abort", onAbort);
}
const cleanup = () => {
for (const signal of signals) {
signal.removeEventListener("abort", onAbort);
}
};
return { signal: controller.signal, cleanup };
}
/**
* Run a callback on the next event loop iteration ("promote" a microtask to a task using setTimeout).
* Note that this is not guaranteed to run on the very next iteration, but it will run as soon as possible.
* This function is designed to avoid the problem of microtasks queueing other microtasks in an infinite loop.
* See the example below for a common pitfall that this function can help avoid.
* Learn more about microtasks and their relation to async/await here:
* https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#control_flow_effects_of_await
* @param cb The callback to run on the next event loop iteration.
* @example
* ```ts
* const signal = AbortSignal.timeout(1_000);
* // This loop will run for 1 second, then stop.
* while (!signal.aborted) {
* await runOnNextEventLoopIteration(() => Promise.resolve());
* }
*
* const signal = AbortSignal.timeout(1_000);
* // This loop will run indefinitely.
* // Each while loop iteration queues a microtask, which itself queues another microtask, and so on.
* while (!signal.aborted) {
* await Promise.resolve();
* }
* ```
*/
function runOnNextEventLoopIteration(cb) {
return new Promise((resolve, reject) => {
setTimeout(() => cb().then(resolve).catch(reject));
});
}
/**
* Merges two observables until the first one completes (or errors).
* The difference between this and `merge` is that this will complete when the first observable completes,
* while `merge` would only complete when _all_ observables complete.
*/
function mergeUntilFirstComplete(observable1, observable2) {
const completionSubject = new rxjs.Subject();
const observable1WithCompletion = observable1.pipe(rxjs.takeUntil(completionSubject), rxjs.finalize(() => completionSubject.next()));
const observable2WithCompletion = observable2.pipe(rxjs.takeUntil(completionSubject), rxjs.finalize(() => completionSubject.next()));
return observable1WithCompletion.pipe(rxjs.mergeWith(observable2WithCompletion));
}
class DemandSpecification {
constructor(
/** Represents the low level demand request body that will be used to subscribe for offers matching our "computational resource needs" */
prototype, paymentPlatform) {
this.prototype = prototype;
this.paymentPlatform = paymentPlatform;
}
}
class Demand {
constructor(id, details) {
this.id = id;
this.details = details;
}
get paymentPlatform() {
return this.details.paymentPlatform;
}
/**
* Demand expiration as a timestamp or null if it's not present in the properties object
*/
get expiration() {
const expirationPropertyValue = this.details.prototype.properties.find((property) => property.key === "golem.srv.comp.expiration");
if (!expirationPropertyValue) {
return null;
}
if (typeof expirationPropertyValue.value === "number") {
return expirationPropertyValue.value;
}
const valuesAsNumber = Number(expirationPropertyValue.value);
if (Number.isNaN(valuesAsNumber)) {
return null;
}
return valuesAsNumber;
}
}
exports.MarketErrorCode = void 0;
(function (MarketErrorCode) {
MarketErrorCode["CouldNotGetAgreement"] = "CouldNotGetAgreement";
MarketErrorCode["CouldNotGetProposal"] = "CouldNotGetProposal";
MarketErrorCode["ServiceNotInitialized"] = "ServiceNotInitialized";
MarketErrorCode["MissingAllocation"] = "MissingAllocation";
MarketErrorCode["SubscriptionFailed"] = "SubscriptionFailed";
MarketErrorCode["InvalidProposal"] = "InvalidProposal";
MarketErrorCode["ProposalResponseFailed"] = "ProposalResponseFailed";
MarketErrorCode["ProposalRejectionFailed"] = "ProposalRejectionFailed";
MarketErrorCode["DemandExpired"] = "DemandExpired";
MarketErrorCode["ResourceRentalTerminationFailed"] = "ResourceRentalTerminationFailed";
MarketErrorCode["ResourceRentalCreationFailed"] = "ResourceRentalCreationFailed";
MarketErrorCode["AgreementApprovalFailed"] = "AgreementApprovalFailed";
MarketErrorCode["NoProposalAvailable"] = "NoProposalAvailable";
MarketErrorCode["InternalError"] = "InternalError";
MarketErrorCode["ScanFailed"] = "ScanFailed";
})(exports.MarketErrorCode || (exports.MarketErrorCode = {}));
class GolemMarketError extends GolemModuleError {
constructor(message, code, previous) {
super(message, code, previous);
this.code = code;
this.previous = previous;
}
}
/**
* Base representation of a market proposal that can be issued either by the Provider (offer proposal)
* or Requestor (counter-proposal)
*/
class MarketProposal {
constructor(model) {
var _a;
this.model = model;
/**
* Reference to the previous proposal in the "negotiation chain"
*
* If null, this means that was the initial offer that the negotiations started from
*/
this.previousProposalId = null;
this.id = model.proposalId;
this.previousProposalId = (_a = model.prevProposalId) !== null && _a !== void 0 ? _a : null;
this.properties = model.properties;
}
get state() {
return this.model.state;
}
get timestamp() {
return new Date(Date.parse(this.model.timestamp));
}
isInitial() {
return this.model.state === "Initial";
}
isDraft() {
return this.model.state === "Draft";
}
isExpired() {
return this.model.state === "Expired";
}
isRejected() {
return this.model.state === "Rejected";
}
isValid() {
try {
this.validate();
return true;
}
catch (err) {
return false;
}
}
}
/**
* Entity representing the offer presented by the Provider to the Requestor
*
* Issue: The final proposal that gets promoted to an agreement comes from the provider
* Right now the last time I can access it directly is when I receive the counter from the provider,
* later it's impossible for me to get it via the API `{"message":"Path deserialize error: Id [2cb0b2820c6142fab5af7a8e90da09f0] has invalid owner type."}`
*
* FIXME #yagna should allow obtaining proposals via the API even if I'm not the owner!
*/
class OfferProposal extends MarketProposal {
constructor(model, demand) {
super(model);
this.demand = demand;
this.issuer = "Provider";
this.validate();
}
get pricing() {
var _a, _b;
const usageVector = this.properties["golem.com.usage.vector"];
const priceVector = this.properties["golem.com.pricing.model.linear.coeffs"];
if (!usageVector) {
throw new GolemInternalError("The proposal does not contain 'golem.com.usage.vector' property. We can't estimate the costs.");
}
if (!priceVector) {
throw new GolemInternalError("The proposal does not contain 'golem.com.pricing.model.linear.coeffs' property. We can't estimate costs.");
}
const envIdx = usageVector.findIndex((ele) => ele === "golem.usage.duration_sec");
const cpuIdx = usageVector.findIndex((ele) => ele === "golem.usage.cpu_sec");
const envSec = (_a = priceVector[envIdx]) !== null && _a !== void 0 ? _a : 0.0;
const cpuSec = (_b = priceVector[cpuIdx]) !== null && _b !== void 0 ? _b : 0.0;
const start = priceVector[priceVector.length - 1];
return {
cpuSec,
envSec,
start,
};
}
getDto() {
return {
transferProtocol: this.properties["golem.activity.caps.transfer.protocol"],
cpuBrand: this.properties["golem.inf.cpu.brand"],
cpuCapabilities: this.properties["golem.inf.cpu.capabilities"],
cpuCores: this.properties["golem.inf.cpu.cores"],
cpuThreads: this.properties["golem.inf.cpu.threads"],
memory: this.properties["golem.inf.mem.gib"],
storage: this.properties["golem.inf.storage.gib"],
publicNet: this.properties["golem.node.net.is-public"],
runtimeCapabilities: this.properties["golem.runtime.capabilities"],
runtimeName: this.properties["golem.runtime.name"],
runtimeVersion: this.properties["golem.runtime.version"],
state: this.state,
};
}
/**
* Cost estimation based on CPU/h, ENV/h and start prices
*
* @param rentHours Number of hours of rental to use for the estimation
*/
getEstimatedCost(rentHours = 1) {
var _a;
const threadsNo = (_a = this.getDto().cpuThreads) !== null && _a !== void 0 ? _a : 1;
const rentSeconds = rentHours * 60 * 60;
return this.pricing.start + this.pricing.cpuSec * threadsNo * rentSeconds + this.pricing.envSec * rentSeconds;
}
get provider() {
return {
id: this.model.issuerId,
name: this.properties["golem.node.id.name"],
walletAddress: this.properties[`golem.com.payment.platform.${this.demand.paymentPlatform}.address`],
};
}
/**
* Validates if the proposal satisfies basic business rules, is complete and thus safe to interact with
*
* Use this method before executing any important logic, to ensure that you're working with correct, complete data
*/
validate() {
const usageVector = this.properties["golem.com.usage.vector"];
const priceVector = this.properties["golem.com.pricing.model.linear.coeffs"];
if (!usageVector || usageVector.length === 0) {
throw new GolemMarketError("Broken proposal: the `golem.com.usage.vector` does not contain valid information about structure of the usage counters vector", exports.MarketErrorCode.InvalidProposal);
}
if (!priceVector || priceVector.length === 0) {
throw new GolemMarketError("Broken proposal: the `golem.com.pricing.model.linear.coeffs` does not contain pricing information", exports.MarketErrorCode.InvalidProposal);
}
if (usageVector.length < priceVector.length - 1) {
throw new GolemMarketError("Broken proposal: the `golem.com.usage.vector` has less pricing information than `golem.com.pricing.model.linear.coeffs`", exports.MarketErrorCode.InvalidProposal);
}
if (priceVector.length < usageVector.length) {
throw new GolemMarketError("Broken proposal: the `golem.com.pricing.model.linear.coeffs` should contain 3 price values", exports.MarketErrorCode.InvalidProposal);
}
}
getProviderPaymentPlatforms() {
return (Object.keys(this.properties)
.filter((prop) => prop.startsWith("golem.com.payment.platform."))
.map((prop) => prop.split(".")[4]) || []);
}
}
/**
* `Promise.withResolvers` is only available in Node 22.0.0 and later.
*/
function withResolvers() {
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { resolve, reject, promise };
}
/**
* A queue of acquirers waiting for an item.
* use `get` to queue up for the next available item.
* use `put` to give the item to the next acquirer.
*/
class AcquireQueue {
constructor() {
this.queue = [];
this.abortController = new AbortController();
}
/**
* Release (reject) all acquirers.
* Essentially this is a way to reset the queue.
*/
releaseAll() {
this.abortController.abort();
this.queue = [];
this.abortController = new AbortController();
}
/**
* Queue up for the next available item.
*/
async get(signalOrTimeout) {
const { signal, cleanup } = anyAbortSignal(createAbortSignalFromTimeout(signalOrTimeout), this.abortController.signal);
signal.throwIfAborted();
const { resolve, promise } = withResolvers();
this.queue.push(resolve);
const abortPromise = new Promise((_, reject) => {
signal.addEventListener("abort", () => {
this.queue = this.queue.filter((r) => r !== resolve);
reject(signal.reason);
});
});
return Promise.race([promise, abortPromise]).finally(cleanup);
}
/**
* Are there any acquirers waiting for an item?
*/
hasAcquirers() {
return this.queue.length > 0;
}
/**
* Give the item to the next acquirer.
* If there are no acquirers, throw an error. You should check `hasAcquirers` before calling this method.
*/
put(item) {
if (!this.hasAcquirers()) {
throw new GolemInternalError("No acquirers waiting for the item");
}
const resolve = this.queue.shift();
resolve(item);
}
size() {
return this.queue.length;
}
}
/**
* Pool of draft offer proposals that are ready to be promoted to agreements with Providers
*
* Reaching this pool means that the related initial proposal which was delivered by Yagna in response
* to the subscription with the Demand has been fully negotiated between the Provider and Requestor.
*
* This pool should contain only offer proposals that can be used to pursue the final Agreement between the
* parties.
*
* Technically, the "market" part of you application should populate this pool with such offer proposals.
*
* It's important to know that offers are never automatically removed from the pool, even if the corresponding
* Demand becomes expired. It's on the application developer to ensure that a proposal is still valid before
* trying to sign an agreement.
*/
class DraftOfferProposalPool {
/**
* Returns a read-only copy of all draft offers currently in the pool
*/
getAvailable() {
return [...this.available];
}
constructor(options) {
this.options = options;
this.events = new eventemitter3.EventEmitter();
this.acquireQueue = new AcquireQueue();
/** {@link ProposalPoolOptions.minCount} */
this.minCount = 0;
/** {@link ProposalPoolOptions.selectOfferProposal} */
this.selectOfferProposal = (proposals) => proposals[0];
/** {@link ProposalPoolOptions.validateOfferProposal} */
this.validateOfferProposal = (proposal) => proposal !== undefined;
/**
* The proposals that were not yet leased to anyone and are available for lease
*/
this.available = new Set();
/**
* The proposal that were already leased to someone and shouldn't be leased again
*/
this.leased = new Set();
if (options === null || options === void 0 ? void 0 : options.selectOfferProposal) {
this.selectOfferProposal = options.selectOfferProposal;
}
if (options === null || options === void 0 ? void 0 : options.validateOfferProposal) {
this.validateOfferProposal = options.validateOfferProposal;
}
if ((options === null || options === void 0 ? void 0 : options.minCount) && options.minCount >= 0) {
this.minCount = options.minCount;
}
this.logger = this.logger = (options === null || options === void 0 ? void 0 : options.logger) || defaultLogger("proposal-pool");
}
/**
* Pushes the provided proposal to the list of proposals available for lease
*/
add(proposal) {
if (!proposal.isDraft()) {
this.logger.error("Cannot add a non-draft proposal to the pool", { proposalId: proposal.id });
throw new GolemMarketError("Cannot add a non-draft proposal to the pool", exports.MarketErrorCode.InvalidProposal);
}
// if someone is waiting for a proposal, give it to them
if (this.acquireQueue.hasAcquirers()) {
this.acquireQueue.put(proposal);
return;
}
this.available.add(proposal);
this.events.emit("added", { proposal });
}
/**
* Attempts to obtain a single proposal from the pool
* @param signalOrTimeout - the timeout in milliseconds or an AbortSignal that will be used to cancel the acquiring
*/
async acquire(signalOrTimeout) {
const signal = createAbortSignalFromTimeout(signalOrTimeout);
signal.throwIfAborted();
// iterate over available proposals until we find a valid one
const tryGettingFromAvailable = async () => {
signal.throwIfAborted();
let proposal = null;
if (this.available.size > 0) {
try {
proposal = this.selectOfferProposal(this.getAvailable());
}
catch (e) {
this.logger.error("Error in user-defined offer proposal selector", { error: e });
}
}
if (!proposal) {
// No proposal was selected, either `available` is empty or the user's proposal filter didn't select anything
// no point retrying
return;
}
if (!this.validateOfferProposal(proposal)) {
// Drop if not valid
this.removeFromAvailable(proposal);
// and try again
return runOnNextEventLoopIteration(tryGettingFromAvailable);
}
// valid proposal found
return proposal;
};
const proposal = await tryGettingFromAvailable();
// Try to get one
if (proposal) {
this.available.delete(proposal);
this.leased.add(proposal);
this.events.emit("acquired", { proposal });
return proposal;
}
// if no valid proposal was found, wait for one to appear
return this.acquireQueue.get(signal);
}
/**
* Releases the proposal back to the pool
*
* Validates if the proposal is still usable before putting it back to the list of available ones
* @param proposal
*/
release(proposal) {
this.leased.delete(proposal);
if (this.validateOfferProposal(proposal)) {
this.events.emit("released", { proposal });
// if someone is waiting for a proposal, give it to them
if (this.acquireQueue.hasAcquirers()) {
this.acquireQueue.put(proposal);
return;
}
// otherwise, put it back to the list of available proposals
this.available.add(proposal);
}
else {
this.events.emit("removed", { proposal });
}
}
remove(proposal) {
if (this.leased.has(proposal)) {
this.leased.delete(proposal);
this.events.emit("removed", { proposal });
}
if (this.available.has(proposal)) {
this.available.delete(proposal);
this.events.emit("removed", { proposal });
}
}
/**
* Returns the number of all items in the pool (available + leased out)
*/
count() {
return this.availableCount() + this.leasedCount();
}
/**
* Returns the number of items that are possible to lease from the pool
*/
availableCount() {
return this.available.size;
}
/**
* Returns the number of items that were leased out of the pool
*/
leasedCount() {
return this.leased.size;
}
/**
* Tells if the pool is ready to take items from
*/
isReady() {
return this.count() >= this.minCount;
}
/**
* Clears the pool entirely
*/
async clear() {
this.acquireQueue.releaseAll();
for (const proposal of this.available) {
this.available.delete(proposal);
this.events.emit("removed", { proposal });
}
for (const proposal of this.leased) {
this.leased.delete(proposal);
this.events.emit("removed", { proposal });
}
this.available = new Set();
this.leased = new Set();
this.events.emit("cleared");
}
removeFromAvailable(proposal) {
this.available.delete(proposal);
this.events.emit("removed", { proposal });
}
readFrom(source) {
return source.subscribe({
next: (proposal) => this.add(proposal),
error: (err) => this.logger.error("Error while collecting proposals", err),
});
}
}
class OfferCounterProposal extends MarketProposal {
constructor(model) {
super(model);
this.issuer = "Requestor";
}
validate() {
return;
}
}
const DEFAULTS$2 = {
minBatchSize: 100,
releaseTimeoutMs: 1000,
};
/**
* Proposals Batch aggregates initial proposals and returns a set grouped by the provider's key
* to avoid duplicate offers issued by the provider.
*/
class ProposalsBatch {
constructor(options) {
var _a, _b;
/** Batch of proposals mapped by provider key and related set of initial proposals */
this.batch = new Map();
/** Lock used to synchronize adding and getting proposals from the batch */
this.lock = new AsyncLock();
this.config = {
minBatchSize: (_a = options === null || options === void 0 ? void 0 : options.minBatchSize) !== null && _a !== void 0 ? _a : DEFAULTS$2.minBatchSize,
releaseTimeoutMs: (_b = options === null || options === void 0 ? void 0 : options.releaseTimeoutMs) !== null && _b !== void 0 ? _b : DEFAULTS$2.releaseTimeoutMs,
};
}
/**
* Add proposal to the batch grouped by provider key
* which consist of providerId, cores, threads, mem and storage
*/
async addProposal(proposal) {
const providerKey = this.getProviderKey(proposal);
await this.lock.acquire("proposals-batch", () => {
let proposals = this.batch.get(providerKey);
if (!proposals) {
proposals = new Set();
this.batch.set(providerKey, proposals);
}
proposals.add(proposal);
});
}
/**
* Returns the batched proposals from the internal buffer and empties it
*/
async getProposals() {
const proposals = [];
await this.lock.acquire("proposals-batch", () => {
this.batch.forEach((providersProposals) => proposals.push(this.getBestProposal(providersProposals)));
this.batch.clear();
});
return proposals;
}
/**
* Waits for the max amount time for batching or max batch size to be reached before it makes sense to process events
*
* Used to flow-control the consumption of the proposal events from the batch.
* The returned promise resolves when it is time to process the buffered proposal events.
*/
async waitForProposals() {
let timeoutId, intervalId;
const isTimeoutReached = new Promise((resolve) => {
timeoutId = setTimeout(resolve, this.config.releaseTimeoutMs);
});
const isBatchSizeReached = new Promise((resolve) => {
intervalId = setInterval(() => {
if (this.batch.size >= this.config.minBatchSize) {
resolve(true);
}
}, 1000);
});
await Promise.race([isTimeoutReached, isBatchSizeReached]);
clearTimeout(timeoutId);
clearInterval(intervalId);
}
/**
* Selects the best proposal from the set according to the lowest price and the youngest proposal age
*/
getBestProposal(proposals) {
const sortByLowerPriceAndHigherTime = (p1, p2) => {
const p1Price = p1.getEstimatedCost();
const p2Price = p2.getEstimatedCost();
const p1Time = p1.timestamp.valueOf();
const p2Time = p2.timestamp.valueOf();
return p1Price !== p2Price ? p1Price - p2Price : p2Time - p1Time;
};
return [...proposals].sort(sortByLowerPriceAndHigherTime)[0];
}
/**
* Provider key used to group proposals so that they can be distinguished based on ID and hardware configuration
*/
getProviderKey(proposal) {
return [
proposal.provider.id,
proposal.properties["golem.inf.cpu.cores"],
proposal.properties["golem.inf.cpu.threads"],
proposal.properties["golem.inf.mem.gib"],
proposal.properties["golem.inf.storage.gib"],
].join("-");
}
}
var ComparisonOperator;
(function (ComparisonOperator) {
ComparisonOperator["Eq"] = "=";
ComparisonOperator["Lt"] = "<";
ComparisonOperator["Gt"] = ">";
ComparisonOperator["GtEq"] = ">=";
ComparisonOperator["LtEq"] = "<=";
})(ComparisonOperator || (ComparisonOperator = {}));
/**
* A helper class assisting in building the Golem Demand object
*
* Various directors should use the builder to add properties and constraints before the final product is received
* from the builder and sent to yagna to subscribe for matched offers (proposals).
*
* The main purpose of the builder is to accept different requirements (properties and constraints) from different
* directors who know what kind of properties and constraints are needed. Then it helps to merge these requirements.
*
* Demand -> DemandSpecification -> DemandPrototype -> DemandDTO
*/
class DemandBodyBuilder {
constructor() {
this.properties = [];
this.constraints = [];
}
addProperty(key, value) {
const findIndex = this.properties.findIndex((prop) => prop.key === key);
if (findIndex >= 0) {
this.properties[findIndex] = { key, value };
}
else {
this.properties.push({ key, value });
}
return this;
}
addConstraint(key, value, comparisonOperator = ComparisonOperator.Eq) {
this.constraints.push({ key, value, comparisonOperator });
return this;
}
getProduct() {
return {
properties: this.properties,
constraints: this.constraints.map((c) => `(${c.key + c.comparisonOperator + c.value})`),
};
}
mergePrototype(prototype) {
if (prototype.properties) {
prototype.properties.forEach((prop) => {
this.addProperty(prop.key, prop.value);
});
}
if (prototype.constraints) {
prototype.constraints.forEach((cons) => {
const { key, value, comparisonOperator } = { ...this.parseConstraint(cons) };
this.addConstraint(key, value, comparisonOperator);
});
}
return this;
}
parseConstraint(constraint) {
for (const key in ComparisonOperator) {
const value = ComparisonOperator[key];
const parsedConstraint = constraint.slice(1, -1).split(value);
if (parsedConstraint.length === 2) {
return {
key: parsedConstraint[0],
value: parsedConstraint[1],
comparisonOperator: value,
};
}
}
throw new GolemInternalError(`Unable to parse constraint "${constraint}"`);
}
}
/**
* Basic config utility class
*
* Helps in building more specific config classes
*/
class BaseConfig {
isPositiveInt(value) {
return value > 0 && Number.isInteger(value);
}
}
var PackageFormat;
(function (PackageFormat) {
PackageFormat["GVMKitSquash"] = "gvmkit-squash";
})(PackageFormat || (PackageFormat = {}));
class WorkloadDemandDirectorConfig extends BaseConfig {
constructor(options) {
var _a;
super();
this.packageFormat = PackageFormat.GVMKitSquash;
this.engine = "vm";
this.runtime = {
name: "vm",
version: undefined,
};
this.minMemGib = 0.5;
this.minStorageGib = 2;
this.minCpuThreads = 1;
this.minCpuCores = 1;
this.capabilities = [];
this.useHttps = false;
Object.assign(this, options);
if (!((_a = options.runtime) === null || _a === void 0 ? void 0 : _a.name)) {
this.runtime.name = this.engine;
}
this.expirationSec = options.expirationSec;
if (!this.imageHash && !this.manifest && !this.imageTag && !this.imageUrl) {
throw new GolemConfigError("You must define a package or manifest option");