diff --git a/sdk/javascript/rustchain-sdk/README.md b/sdk/javascript/rustchain-sdk/README.md index cdf4bd486..726ea47cf 100644 --- a/sdk/javascript/rustchain-sdk/README.md +++ b/sdk/javascript/rustchain-sdk/README.md @@ -23,6 +23,7 @@ const client = new RustChainClient({ const health = await client.health(); const epoch = await client.epoch(); const miners = await client.miners({ limit: 10 }); +const openJobs = await client.listJobs({ category: "code", limit: 10 }); console.log({ health, epoch, miners }); ``` @@ -47,6 +48,18 @@ Options: - `attestChallenge(payload)` -> `POST /attest/challenge` - `submitAttestation(payload)` -> `POST /attest/submit` - `transferHistory(wallet, { limit })` -> `GET /wallet/history?miner_id=...` +- `listJobs({ category, status, limit, offset, minReward })` -> `GET /agent/jobs` +- `getJob(jobId)` -> `GET /agent/jobs/:jobId` +- `postJob({ posterWallet, title, description, category, rewardRtc, ttlSeconds, tags })` -> `POST /agent/jobs` +- `claimJob(jobId, workerWallet)` -> `POST /agent/jobs/:jobId/claim` +- `deliverJob(jobId, { workerWallet, deliverableUrl, deliverableHash, resultSummary })` -> `POST /agent/jobs/:jobId/deliver` +- `acceptJob(jobId, { posterWallet, rating })` -> `POST /agent/jobs/:jobId/accept` +- `disputeJob(jobId, posterWallet, reason)` -> `POST /agent/jobs/:jobId/dispute` +- `cancelJob(jobId, posterWallet)` -> `POST /agent/jobs/:jobId/cancel` +- `reputation(walletId)` -> `GET /agent/reputation/:walletId` +- `agentStats()` -> `GET /agent/stats` + +Agent-economy methods mirror the RIP-302 marketplace API and return the parsed JSON envelope, including pagination and payment metadata where the server provides it. They do not create production jobs or send funds unless the caller explicitly invokes those methods with real wallet data. ## Example diff --git a/sdk/javascript/rustchain-sdk/src/index.js b/sdk/javascript/rustchain-sdk/src/index.js index 7a556a732..ef0c78ca0 100644 --- a/sdk/javascript/rustchain-sdk/src/index.js +++ b/sdk/javascript/rustchain-sdk/src/index.js @@ -1,6 +1,19 @@ const DEFAULT_BASE_URL = "https://rustchain.org"; const DEFAULT_TIMEOUT_MS = 30000; +export const AGENT_JOB_CATEGORIES = Object.freeze([ + "research", + "code", + "video", + "audio", + "writing", + "translation", + "data", + "design", + "testing", + "other" +]); + export class RustChainError extends Error { constructor(message, options = {}) { super(message); @@ -127,6 +140,119 @@ export class RustChainClient { return []; } + async listJobs(options = {}) { + const params = new URLSearchParams(); + if (options.category !== undefined) { + assertAgentJobCategory(options.category); + params.set("category", options.category); + } + if (options.status !== undefined) { + assertNonEmptyString(options.status, "status"); + params.set("status", options.status); + } + if (options.limit !== undefined) { + const limit = validateNonNegativeInteger(options.limit, "limit"); + if (limit > 100) throw new RustChainValidationError("limit must be at most 100"); + params.set("limit", String(limit)); + } + if (options.offset !== undefined) { + params.set("offset", String(validateNonNegativeInteger(options.offset, "offset"))); + } + if (options.minReward !== undefined) { + params.set("min_reward", String(validateNonNegativeNumber(options.minReward, "minReward"))); + } + return this.request("GET", withQuery("/agent/jobs", params)); + } + + async getJob(jobId) { + assertNonEmptyString(jobId, "jobId"); + return this.request("GET", `/agent/jobs/${encodeURIComponent(jobId)}`); + } + + async postJob(options = {}) { + assertObject(options, "postJob options"); + assertNonEmptyString(options.posterWallet, "posterWallet"); + assertMinLength(options.title, 5, "title"); + assertMinLength(options.description, 20, "description"); + assertAgentJobCategory(options.category ?? "other"); + const rewardRtc = validateRangeNumber(options.rewardRtc, "rewardRtc", 0.01, 10000); + const ttlSeconds = validatePositiveInteger(options.ttlSeconds, "ttlSeconds"); + + return this.request("POST", "/agent/jobs", { + poster_wallet: options.posterWallet, + title: options.title, + description: options.description, + category: options.category ?? "other", + reward_rtc: rewardRtc, + ttl_seconds: ttlSeconds, + ...(options.tags !== undefined ? { tags: normalizeTags(options.tags) } : {}) + }); + } + + async claimJob(jobId, workerWallet) { + assertNonEmptyString(jobId, "jobId"); + assertNonEmptyString(workerWallet, "workerWallet"); + return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/claim`, { + worker_wallet: workerWallet + }); + } + + async deliverJob(jobId, options = {}) { + assertNonEmptyString(jobId, "jobId"); + assertObject(options, "deliverJob options"); + assertNonEmptyString(options.workerWallet, "workerWallet"); + if (options.deliverableUrl === undefined && options.resultSummary === undefined) { + throw new RustChainValidationError("deliverableUrl or resultSummary is required"); + } + const body = { + worker_wallet: options.workerWallet, + ...(options.deliverableUrl !== undefined ? { deliverable_url: String(options.deliverableUrl) } : {}), + ...(options.deliverableHash !== undefined ? { deliverable_hash: String(options.deliverableHash) } : {}), + ...(options.resultSummary !== undefined ? { result_summary: String(options.resultSummary) } : {}) + }; + return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/deliver`, body); + } + + async acceptJob(jobId, options = {}) { + assertNonEmptyString(jobId, "jobId"); + assertObject(options, "acceptJob options"); + assertNonEmptyString(options.posterWallet, "posterWallet"); + const body = { poster_wallet: options.posterWallet }; + if (options.rating !== undefined) { + const rating = validateRangeNumber(options.rating, "rating", 1, 5); + if (!Number.isInteger(rating)) throw new RustChainValidationError("rating must be an integer from 1 to 5"); + body.rating = rating; + } + return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/accept`, body); + } + + async disputeJob(jobId, posterWallet, reason) { + assertNonEmptyString(jobId, "jobId"); + assertNonEmptyString(posterWallet, "posterWallet"); + assertNonEmptyString(reason, "reason"); + return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/dispute`, { + poster_wallet: posterWallet, + reason + }); + } + + async cancelJob(jobId, posterWallet) { + assertNonEmptyString(jobId, "jobId"); + assertNonEmptyString(posterWallet, "posterWallet"); + return this.request("POST", `/agent/jobs/${encodeURIComponent(jobId)}/cancel`, { + poster_wallet: posterWallet + }); + } + + async reputation(walletId) { + assertNonEmptyString(walletId, "walletId"); + return this.request("GET", `/agent/reputation/${encodeURIComponent(walletId)}`); + } + + async agentStats() { + return this.request("GET", "/agent/stats"); + } + async request(method, endpoint, body) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); @@ -211,6 +337,31 @@ function assertNonEmptyString(value, name) { } } +function assertObject(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new RustChainValidationError(`${name} must be an object`); + } +} + +function assertMinLength(value, minLength, name) { + assertNonEmptyString(value, name); + if (value.trim().length < minLength) { + throw new RustChainValidationError(`${name} must be at least ${minLength} characters`); + } +} + +function assertAgentJobCategory(value) { + assertNonEmptyString(value, "category"); + if (!AGENT_JOB_CATEGORIES.includes(value)) { + throw new RustChainValidationError(`category must be one of: ${AGENT_JOB_CATEGORIES.join(", ")}`); + } +} + +function normalizeTags(value) { + if (Array.isArray(value)) return value.map((tag) => String(tag)); + return String(value); +} + function isRtcAddress(value) { return typeof value === "string" && /^RTC[0-9a-fA-F]{40}$/.test(value); } @@ -246,3 +397,11 @@ function validateNonNegativeNumber(value, name) { } return number; } + +function validateRangeNumber(value, name, minimum, maximum) { + const number = Number(value); + if (!Number.isFinite(number) || number < minimum || number > maximum) { + throw new RustChainValidationError(`${name} must be between ${minimum} and ${maximum}`); + } + return number; +} diff --git a/sdk/javascript/rustchain-sdk/test/rustchain.test.js b/sdk/javascript/rustchain-sdk/test/rustchain.test.js index e2dd51269..b0629b7e8 100644 --- a/sdk/javascript/rustchain-sdk/test/rustchain.test.js +++ b/sdk/javascript/rustchain-sdk/test/rustchain.test.js @@ -147,6 +147,106 @@ test("normalizes legacy array transfer history responses", async () => { assert.deepEqual(await client.transferHistory("alice"), [{ tx_hash: "legacy", amount: 1 }]); }); +test("lists agent jobs with pagination and filters", async () => { + const client = new RustChainClient({ + fetch: mockFetch((url, init) => { + assert.equal(url, "https://rustchain.org/agent/jobs?category=code&status=open&limit=10&offset=2&min_reward=5"); + assert.equal(init.method, "GET"); + return { status: 200, body: JSON.stringify({ ok: true, jobs: [{ job_id: "job-1" }], total: 1 }) }; + }) + }); + + assert.deepEqual(await client.listJobs({ category: "code", status: "open", limit: 10, offset: 2, minReward: 5 }), { + ok: true, + jobs: [{ job_id: "job-1" }], + total: 1 + }); +}); + +test("posts an agent job with the RIP-302 payload", async () => { + const client = new RustChainClient({ + fetch: mockFetch((url, init) => { + assert.equal(url, "https://rustchain.org/agent/jobs"); + assert.equal(init.method, "POST"); + assert.deepEqual(JSON.parse(init.body), { + poster_wallet: "RTCposter", + title: "Find a useful product", + description: "Research and compare three useful products for a client.", + category: "research", + reward_rtc: 12.5, + ttl_seconds: 86400, + tags: ["research", "shopping"] + }); + return { status: 201, body: JSON.stringify({ ok: true, job_id: "job-1" }) }; + }) + }); + + assert.deepEqual(await client.postJob({ + posterWallet: "RTCposter", + title: "Find a useful product", + description: "Research and compare three useful products for a client.", + category: "research", + rewardRtc: 12.5, + ttlSeconds: 86400, + tags: ["research", "shopping"] + }), { ok: true, job_id: "job-1" }); +}); + +test("supports the RIP-302 job lifecycle endpoints", async () => { + const calls = []; + const client = new RustChainClient({ + fetch: mockFetch((url, init) => { + calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined }); + return { status: 200, body: JSON.stringify({ ok: true }) }; + }) + }); + + await client.getJob("job/1"); + await client.claimJob("job/1", "RTCworker"); + await client.deliverJob("job/1", { + workerWallet: "RTCworker", + deliverableUrl: "https://example.test/result", + deliverableHash: "sha256:abc", + resultSummary: "Completed" + }); + await client.acceptJob("job/1", { posterWallet: "RTCposter", rating: 5 }); + await client.disputeJob("job/1", "RTCposter", "Needs review"); + await client.cancelJob("job/1", "RTCposter"); + await client.reputation("RTCworker/id"); + await client.agentStats(); + + assert.deepEqual(calls, [ + { url: "https://rustchain.org/agent/jobs/job%2F1", method: "GET", body: undefined }, + { url: "https://rustchain.org/agent/jobs/job%2F1/claim", method: "POST", body: { worker_wallet: "RTCworker" } }, + { + url: "https://rustchain.org/agent/jobs/job%2F1/deliver", + method: "POST", + body: { + worker_wallet: "RTCworker", + deliverable_url: "https://example.test/result", + deliverable_hash: "sha256:abc", + result_summary: "Completed" + } + }, + { url: "https://rustchain.org/agent/jobs/job%2F1/accept", method: "POST", body: { poster_wallet: "RTCposter", rating: 5 } }, + { url: "https://rustchain.org/agent/jobs/job%2F1/dispute", method: "POST", body: { poster_wallet: "RTCposter", reason: "Needs review" } }, + { url: "https://rustchain.org/agent/jobs/job%2F1/cancel", method: "POST", body: { poster_wallet: "RTCposter" } }, + { url: "https://rustchain.org/agent/reputation/RTCworker%2Fid", method: "GET", body: undefined }, + { url: "https://rustchain.org/agent/stats", method: "GET", body: undefined } + ]); +}); + +test("validates agent job inputs before making requests", async () => { + const client = new RustChainClient({ fetch: mockFetch(() => ({ status: 200 })) }); + + await assert.rejects( + () => client.postJob({ posterWallet: "RTCposter", title: "No", description: "This description is long enough.", category: "code", rewardRtc: 1, ttlSeconds: 3600 }), + RustChainValidationError + ); + await assert.rejects(() => client.listJobs({ category: "invalid" }), RustChainValidationError); + await assert.rejects(() => client.deliverJob("job-1", { workerWallet: "RTCworker" }), RustChainValidationError); +}); + test("throws API errors with status and endpoint", async () => { const client = new RustChainClient({ fetch: mockFetch(() => ({ status: 500, body: JSON.stringify({ error: "boom" }) }))