UNPKG

@nosana/kit

Version:

Nosana KIT

564 lines (563 loc) 22.6 kB
import { BaseProgram } from './BaseProgram.js'; import { generateKeyPairSigner, parseBase64RpcAccount } from 'gill'; import { ErrorCodes, NosanaError } from '../index.js'; import * as programClient from "../generated_clients/jobs/index.js"; import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; import bs58 from 'bs58'; import { IPFS } from '../ipfs/IPFS.js'; import { convertBigIntToNumber } from '../utils/index.js'; export var JobState; (function (JobState) { JobState[JobState["QUEUED"] = 0] = "QUEUED"; JobState[JobState["RUNNING"] = 1] = "RUNNING"; JobState[JobState["COMPLETED"] = 2] = "COMPLETED"; JobState[JobState["STOPPED"] = 3] = "STOPPED"; })(JobState || (JobState = {})); export var MarketQueueType; (function (MarketQueueType) { MarketQueueType[MarketQueueType["JOB_QUEUE"] = 0] = "JOB_QUEUE"; MarketQueueType[MarketQueueType["NODE_QUEUE"] = 1] = "NODE_QUEUE"; })(MarketQueueType || (MarketQueueType = {})); export class JobsProgram extends BaseProgram { constructor(sdk) { super(sdk); this.client = programClient; } getProgramId() { return this.sdk.config.programs.jobsAddress; } /** * Fetch a job account by address */ async get(addr, checkRun = true) { try { const jobAccount = await this.client.fetchJobAccount(this.sdk.solana.rpc, addr); const job = this.transformJobAccount(jobAccount); if (checkRun && job.state === JobState.QUEUED) { // If job is queued, check if there is a run account for the job const runs = await this.runs({ job: job.address }); if (runs.length > 0) { const run = runs[0]; job.state = JobState.RUNNING; job.timeStart = run.time; job.node = run.node; } } return job; } catch (err) { this.sdk.logger.error(`Failed to fetch job ${err}`); throw err; } } /** * Fetch a run account by address */ async run(addr) { try { const runAccount = await this.client.fetchRunAccount(this.sdk.solana.rpc, addr); const run = this.transformRunAccount(runAccount); return run; } catch (err) { this.sdk.logger.error(`Failed to fetch run ${err}`); throw err; } } /** * Fetch a run account by address */ async market(addr) { try { const marketAccount = await this.client.fetchMarketAccount(this.sdk.solana.rpc, addr); const market = this.transformMarketAccount(marketAccount); return market; } catch (err) { this.sdk.logger.error(`Failed to fetch market ${err}`); throw err; } } /** * Fetch multiple job accounts by address */ async multiple(addresses, checkRuns = false) { try { const jobAccounts = await this.client.fetchAllJobAccount(this.sdk.solana.rpc, addresses); ; const jobs = jobAccounts.map(jobAccount => (this.transformJobAccount(jobAccount))); if (checkRuns) { const runs = await this.runs(); jobs.forEach(job => { if (job.state === JobState.QUEUED) { const run = runs.find(run => run.job === job.address); if (run) { job.state = JobState.RUNNING; job.timeStart = run.time; job.node = run.node; } } }); } return jobs; } catch (err) { this.sdk.logger.error(`Failed to fetch job ${err}`); throw err; } } /** * Fetch all job accounts */ async all(filters, checkRuns = false) { try { const extraGPAFilters = []; if (filters) { if (typeof filters.state === 'number') { extraGPAFilters.push({ memcmp: { offset: BigInt(208), bytes: bs58.encode(Buffer.from([filters.state])), encoding: "base58", }, }); } if (filters.project) { extraGPAFilters.push({ memcmp: { offset: BigInt(176), bytes: filters.project.toString(), encoding: "base58", }, }); } if (filters.node) { extraGPAFilters.push({ memcmp: { offset: BigInt(104), bytes: filters.node.toString(), encoding: "base58", }, }); } if (filters.market) { extraGPAFilters.push({ memcmp: { offset: BigInt(72), bytes: filters.market.toString(), encoding: "base58", }, }); } } const getProgramAccountsResponse = await this.sdk.solana.rpc .getProgramAccounts(this.getProgramId(), { encoding: "base64", filters: [ { memcmp: { offset: BigInt(0), bytes: bs58.encode(Buffer.from(programClient.JOB_ACCOUNT_DISCRIMINATOR)), encoding: "base58", }, }, ...extraGPAFilters, ], }) .send(); const jobs = getProgramAccountsResponse .map((result) => { try { const jobAccount = programClient.decodeJobAccount(parseBase64RpcAccount(result.pubkey, result.account)); return this.transformJobAccount(jobAccount); } catch (err) { this.sdk.logger.error(`Failed to decode job ${err}`); return null; } }) .filter((account) => account !== null); if (checkRuns) { const runs = await this.runs(); jobs.forEach(job => { if (job.state === JobState.QUEUED) { const run = runs.find(run => run.job === job.address); if (run) { job.state = JobState.RUNNING; job.timeStart = run.time; job.node = run.node; } } }); } return jobs; } catch (err) { this.sdk.logger.error(`Failed to fetch all jobs ${err}`); throw err; } } /** * Fetch all run accounts */ async runs(filters) { try { const extraGPAFilters = []; if (filters) { if (filters.node) { extraGPAFilters.push({ memcmp: { offset: BigInt(40), bytes: filters.node.toString(), encoding: "base58", }, }); } if (filters.job) { extraGPAFilters.push({ memcmp: { offset: BigInt(8), bytes: filters.job.toString(), encoding: "base58", }, }); } } const getProgramAccountsResponse = await this.sdk.solana.rpc .getProgramAccounts(this.getProgramId(), { encoding: "base64", filters: [ { memcmp: { offset: BigInt(0), bytes: bs58.encode(Buffer.from(programClient.RUN_ACCOUNT_DISCRIMINATOR)), encoding: "base58", }, }, ], }) .send(); const runAccounts = getProgramAccountsResponse .map((result) => { try { const runAccount = programClient.decodeRunAccount(parseBase64RpcAccount(result.pubkey, result.account)); return this.transformRunAccount(runAccount); } catch (err) { this.sdk.logger.error(`Failed to decode run ${err}`); return null; } }) .filter((account) => account !== null); return runAccounts; } catch (err) { this.sdk.logger.error(`Failed to fetch all runs ${err}`); throw err; } } /** * Fetch all market accounts */ async markets() { try { const getProgramAccountsResponse = await this.sdk.solana.rpc .getProgramAccounts(this.getProgramId(), { encoding: "base64", filters: [ { memcmp: { offset: BigInt(0), bytes: bs58.encode(Buffer.from(programClient.MARKET_ACCOUNT_DISCRIMINATOR)), encoding: "base58", }, }, ], }) .send(); const marketAccounts = getProgramAccountsResponse .map((result) => { try { const marketAccount = programClient.decodeMarketAccount(parseBase64RpcAccount(result.pubkey, result.account)); return this.transformMarketAccount(marketAccount); } catch (err) { this.sdk.logger.error(`Failed to decode market ${err}`); return null; } }) .filter((account) => account !== null); return marketAccounts; } catch (err) { this.sdk.logger.error(`Failed to fetch all markets ${err}`); throw err; } } /** * Post a new job to the marketplace * @param params Parameters for listing a job * @returns The transaction signature */ async post(params) { const jobKey = await generateKeyPairSigner(); const runKey = await generateKeyPairSigner(); const [associatedTokenAddress] = await findAssociatedTokenPda({ mint: this.sdk.config.programs.nosTokenAddress, owner: this.sdk.wallet.address, tokenProgram: TOKEN_PROGRAM_ADDRESS }); try { const staticAccounts = await this.getStaticAccounts(); // Create the list instruction const instruction = this.client.getListInstruction({ job: jobKey, market: params.market, run: runKey, user: associatedTokenAddress, vault: await this.sdk.solana.pda([ params.market, this.sdk.config.programs.nosTokenAddress, ], staticAccounts.jobsProgram), payer: this.sdk.wallet, rewardsReflection: staticAccounts.rewardsReflection, rewardsVault: staticAccounts.rewardsVault, authority: this.sdk.wallet, rewardsProgram: staticAccounts.rewardsProgram, ipfsJob: bs58.decode(params.ipfsHash).subarray(2), timeout: params.timeout }); return instruction; } catch (err) { const errorMessage = `Failed to create list instruction: ${err instanceof Error ? err.message : String(err)}`; this.sdk.logger.error(errorMessage); throw new Error(errorMessage); } } /** * Monitor program account updates using callback functions * Uses WebSocket subscriptions with automatic restart on failure * * @example * ```typescript * // Example: Monitor job accounts and save to file * const stopMonitoring = await jobsProgram.monitor({ * onJobAccount: async (jobAccount) => { * console.log('Job updated:', jobAccount.address.toString()); * // Save to database, file, or process as needed * }, * onRunAccount: async (runAccount) => { * console.log('Run updated:', runAccount.address.toString()); * }, * onError: async (error, accountType) => { * console.error('Error processing account:', error, accountType); * } * }); * * // Stop monitoring when done * stopMonitoring(); * ``` * * @param options Configuration options for monitoring * @returns A function to stop monitoring */ async monitor(options = {}) { const { onJobAccount, onMarketAccount, onRunAccount, onError } = options; const programId = this.getProgramId(); try { this.sdk.logger.info(`Starting to monitor job program account updates for program: ${programId}`); let abortController = null; let isMonitoring = true; // Function to stop all monitoring const stopMonitoring = () => { isMonitoring = false; if (abortController) { abortController.abort(); } this.sdk.logger.info(`Stopped monitoring job program account updates`); }; // Function to start/restart subscription with retry logic const startSubscription = async () => { while (isMonitoring) { try { this.sdk.logger.info('Attempting to establish WebSocket subscription...'); abortController = new AbortController(); const subscriptionIterable = await this.setupSubscription(abortController); this.sdk.logger.info('Successfully established WebSocket subscription'); // Start processing subscription notifications await this.processSubscriptionNotifications(subscriptionIterable, { onJobAccount, onMarketAccount, onRunAccount, onError }, () => isMonitoring); } catch (error) { this.sdk.logger.warn(`WebSocket subscription failed: ${error}`); // Clean up current subscription if (abortController) { abortController.abort(); abortController = null; } if (isMonitoring) { this.sdk.logger.info('Retrying WebSocket subscription in 5 seconds...'); await new Promise(resolve => setTimeout(resolve, 5000)); } } } }; // Start the subscription loop startSubscription().catch(error => { this.sdk.logger.error(`Failed to start subscription loop: ${error}`); }); this.sdk.logger.info(`Successfully started monitoring job program account updates`); return stopMonitoring; } catch (error) { this.sdk.logger.error(`Failed to start monitoring job program accounts: ${error}`); throw new NosanaError('Failed to start monitoring job program accounts', ErrorCodes.RPC_ERROR, error); } } /** * Set up WebSocket subscription for program notifications */ async setupSubscription(abortController) { try { // Set up the subscription using the correct API pattern const subscriptionIterable = await this.sdk.solana.rpcSubscriptions .programNotifications(this.getProgramId(), { encoding: 'base64' }) .subscribe({ abortSignal: abortController.signal }); return subscriptionIterable; } catch (error) { throw new Error(`Failed to setup subscription: ${error}`); } } /** * Process subscription notifications */ async processSubscriptionNotifications(notificationIterable, options, isMonitoring) { try { for await (const notification of notificationIterable) { // Check if monitoring should continue if (!isMonitoring()) { this.sdk.logger.info('Monitoring stopped, exiting subscription processing'); break; } try { const { value } = notification; await this.handleAccountUpdate(value, options, isMonitoring); } catch (error) { this.sdk.logger.error(`Error handling account update notification: ${error}`); if (options.onError) { try { await options.onError(error instanceof Error ? error : new Error(String(error))); } catch (callbackError) { this.sdk.logger.error(`Error in onError callback: ${callbackError}`); } } } } } catch (error) { this.sdk.logger.error(`Subscription error: ${error}`); // Throw the error so the calling function can restart the subscription throw error; } } transformJobAccount(jobAccount) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { discriminator: _, ...jobAccountData } = jobAccount.data; const converted = convertBigIntToNumber(jobAccountData); return { address: jobAccount.address, ...converted, ipfsJob: IPFS.solHashToIpfsHash(jobAccountData.ipfsJob), ipfsResult: IPFS.solHashToIpfsHash(jobAccountData.ipfsResult), state: converted.state, }; } transformRunAccount(runAccount) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { discriminator: _, ...runAccountData } = runAccount.data; return { address: runAccount.address, ...convertBigIntToNumber(runAccountData), }; } transformMarketAccount(marketAccount) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { discriminator: _, ...marketAccountData } = marketAccount.data; const converted = convertBigIntToNumber(marketAccountData); return { address: marketAccount.address, ...converted, queueType: converted.queueType, }; } /** * Handle account update using callback functions */ async handleAccountUpdate(accountData, options, isMonitoring) { try { const { account, pubkey } = accountData; const encodedAccount = parseBase64RpcAccount(pubkey, account); const accountType = programClient.identifyNosanaJobsAccount(encodedAccount); switch (accountType) { case programClient.NosanaJobsAccount.JobAccount: const jobAccount = programClient.decodeJobAccount(encodedAccount); await this.handleJobAccount(jobAccount, options.onJobAccount, isMonitoring); break; case programClient.NosanaJobsAccount.MarketAccount: const marketAccount = programClient.decodeMarketAccount(encodedAccount); await this.handleMarketAccount(marketAccount, options.onMarketAccount, isMonitoring); break; case programClient.NosanaJobsAccount.RunAccount: const runAccount = programClient.decodeRunAccount(encodedAccount); await this.handleRunAccount(runAccount, options.onRunAccount, isMonitoring); break; default: this.sdk.logger.error(`No support yet for account type: ${accountType}`); return; } } catch (error) { this.sdk.logger.error(`Error in handleAccountUpdate: ${error}`); } } async handleJobAccount(jobAccount, onJobAccount, _isMonitoring) { if (onJobAccount) { try { await onJobAccount(this.transformJobAccount(jobAccount)); } catch (error) { this.sdk.logger.error(`Error in onJobAccount callback: ${error}`); throw error; } } this.sdk.logger.debug(`Processed job account ${jobAccount.address.toString()}`); } // eslint-disable-next-line @typescript-eslint/no-unused-vars async handleMarketAccount(marketAccount, onMarketAccount, _isMonitoring) { if (onMarketAccount) { try { await onMarketAccount(this.transformMarketAccount(marketAccount)); } catch (error) { this.sdk.logger.error(`Error in onMarketAccount callback: ${error}`); throw error; } } this.sdk.logger.debug(`Processed market account ${marketAccount.address.toString()}`); } async handleRunAccount(runAccount, onRunAccount, _isMonitoring) { if (onRunAccount) { try { await onRunAccount(this.transformRunAccount(runAccount)); } catch (error) { this.sdk.logger.error(`Error in onRunAccount callback: ${error}`); throw error; } } this.sdk.logger.debug(`Processed run account ${runAccount.address.toString()}`); } }