UNPKG

@golem-sdk/golem-js

Version:

NodeJS and WebBrowser SDK for building apps running on Golem Network

1,287 lines (1,260 loc) 302 kB
'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");