diff --git a/docs/examples/taskmarket-plugin/README.md b/docs/examples/taskmarket-plugin/README.md new file mode 100644 index 0000000..39ebf00 --- /dev/null +++ b/docs/examples/taskmarket-plugin/README.md @@ -0,0 +1,80 @@ +# TaskMarket Delegation Plugin + +This example plugin lets Franklin inspect public TaskMarket work and prepare +new TaskMarket delegation tasks without silently spending funds. + +Franklin is wallet-native, so the plugin deliberately separates read-only +discovery from money-moving actions: + +- `discover` reads open public TaskMarket tasks through the HTTP API. +- `prepare-task` builds an explicit `taskmarket task create` command from a + Franklin task specification. +- `review-submissions` builds the commands needed to inspect, accept, or reject + submissions. + +The plugin does not create tasks, submit work, accept results, or spend USDC on +its own. Franklin should show the prepared command and require user approval or +a configured spending policy before executing the TaskMarket CLI. + +## Install Locally + +```bash +mkdir -p ~/.blockrun/plugins +cp -R docs/examples/taskmarket-plugin ~/.blockrun/plugins/taskmarket-delegation +franklin taskmarket-delegation run --dry +``` + +For development without copying: + +```bash +FRANKLIN_PLUGINS_DIR=docs/examples franklin taskmarket-delegation run --dry +``` + +## Configuration + +The workflow default is intentionally conservative: + +```json +{ + "taskmarketApiUrl": "https://api.taskmarket.dev", + "maxTaskBudgetUsdc": 1, + "maxBrowseResults": 10 +} +``` + +Set `TASKMARKET_API_URL` to point at a staging backend when testing a local +TaskMarket deployment. + +## Example Delegation Flow + +1. Franklin detects that a request needs external work, research, data + collection, benchmarking, or verification. +2. Franklin runs the `discover` step to see whether existing TaskMarket tasks + already cover the need. +3. If no suitable task exists, Franklin calls `prepare-task` with: + +```json +{ + "taskSpec": { + "title": "Verify an API endpoint list", + "description": "Check the listed endpoints, report which work without auth, and include curl receipts.", + "acceptanceCriteria": "- Include tested URLs\n- Include HTTP status codes\n- Flag endpoints requiring paid keys", + "rewardUsdc": 1, + "tags": ["api", "verification", "franklin"] + } +} +``` + +4. Franklin displays the generated `taskmarket task create ...` command. +5. The operator approves the spend, or Franklin applies a preconfigured policy. +6. After submissions arrive, Franklin uses `review-submissions` and presents + candidates for approval before accepting or rejecting any worker. + +## Safety Rules + +- Never pass private keys or seed phrases through plugin config. +- Never create a TaskMarket task from untrusted prompt text without review. +- Never exceed `maxTaskBudgetUsdc` without explicit approval. +- Never accept or reject worker submissions without explicit authorization or a + clearly configured policy. +- Encrypt sensitive artifacts before upload. diff --git a/docs/examples/taskmarket-plugin/index.js b/docs/examples/taskmarket-plugin/index.js new file mode 100644 index 0000000..56c6e8c --- /dev/null +++ b/docs/examples/taskmarket-plugin/index.js @@ -0,0 +1,186 @@ +import { DEFAULT_MODEL_TIERS } from "@blockrun/franklin/plugin-sdk"; + +const DEFAULT_API_URL = "https://api.taskmarket.dev"; + +function apiBase(config) { + return String(config?.taskmarketApiUrl || process.env.TASKMARKET_API_URL || DEFAULT_API_URL).replace(/\/+$/, ""); +} + +function asPositiveNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function describeTask(task) { + const reward = Number(task.netReward || task.reward || 0) / 1_000_000; + return { + id: task.id, + mode: task.mode, + status: task.status, + rewardUsdc: Number(reward.toFixed(6)), + submissions: task.submissionCount || 0, + expiresAt: task.expiryTime, + tags: task.tags || [], + preview: String(task.description || "").replace(/\s+/g, " ").slice(0, 220), + }; +} + +async function fetchJson(url) { + const response = await fetch(url, { + headers: { + "accept": "application/json", + "user-agent": "franklin-taskmarket-delegation-plugin/0.1.0", + }, + }); + if (!response.ok) { + throw new Error(`TaskMarket API returned ${response.status} for ${url}`); + } + return response.json(); +} + +function buildTaskCreateCommand(taskSpec, config) { + const rewardUsdc = asPositiveNumber(taskSpec.rewardUsdc, asPositiveNumber(config?.maxTaskBudgetUsdc, 1)); + const title = taskSpec.title || "Delegated Franklin task"; + const description = [ + `# ${title}`, + "", + taskSpec.description || "Franklin identified this work as better delegated to external workers.", + "", + "## Acceptance criteria", + taskSpec.acceptanceCriteria || "- Submit a clear artifact or proof that satisfies the brief.", + "", + "## Authorization", + "Created only after explicit user approval from the Franklin operator.", + ].join("\n"); + + return [ + "taskmarket", + "task", + "create", + "--mode", + "bounty", + "--reward", + String(rewardUsdc), + "--description", + shellQuote(description), + "--tags", + shellQuote((taskSpec.tags || ["franklin", "delegation"]).join(",")), + ].join(" "); +} + +const workflow = { + id: "taskmarket-delegation", + name: "TaskMarket Delegation", + description: "Route work to TaskMarket when Franklin should delegate instead of spending more inference.", + + defaultConfig() { + return { + name: "taskmarket-delegation", + taskmarketApiUrl: DEFAULT_API_URL, + maxTaskBudgetUsdc: 1, + maxBrowseResults: 10, + models: { ...DEFAULT_MODEL_TIERS }, + }; + }, + + onboardingQuestions: [ + { + id: "maxTaskBudgetUsdc", + prompt: "Maximum USDC Franklin may prepare for a TaskMarket task before asking for approval", + type: "text", + }, + ], + + async buildConfigFromAnswers(answers) { + return { + name: "taskmarket-delegation", + taskmarketApiUrl: DEFAULT_API_URL, + maxTaskBudgetUsdc: asPositiveNumber(answers.maxTaskBudgetUsdc, 1), + maxBrowseResults: 10, + models: { ...DEFAULT_MODEL_TIERS }, + }; + }, + + steps: [ + { + name: "discover", + modelTier: "none", + execute: async (ctx) => { + const config = ctx.config || {}; + const max = Math.min(asPositiveNumber(config.maxBrowseResults, 10), 25); + const url = `${apiBase(config)}/api/tasks?status=open&limit=${max}&sort=reward_desc`; + const payload = await fetchJson(url); + const tasks = (payload.tasks || []) + .filter((task) => + task.status === "open" && + task.submissionWindowOpen === true && + task.taskVisibility === "public" && + task.hasAccessPassword === false && + task.stakeRequired === false + ) + .map(describeTask); + + return { + summary: `Found ${tasks.length} public TaskMarket tasks Franklin can inspect.`, + data: { tasks }, + }; + }, + }, + { + name: "prepare-task", + modelTier: "none", + execute: async (ctx) => { + const taskSpec = ctx.input?.taskSpec || {}; + const command = buildTaskCreateCommand(taskSpec, ctx.config || {}); + return { + summary: "Prepared a TaskMarket creation command; execute it only after explicit user approval.", + data: { + requiresUserApproval: true, + spendingLimitUsdc: asPositiveNumber(ctx.config?.maxTaskBudgetUsdc, 1), + command, + }, + }; + }, + }, + { + name: "review-submissions", + modelTier: "none", + execute: async (ctx) => { + const taskId = ctx.input?.taskId; + if (!/^0x[a-fA-F0-9]{64}$/.test(String(taskId || ""))) { + throw new Error("review-submissions requires a 0x-prefixed 32-byte TaskMarket task id"); + } + return { + summary: "Prepared review commands; accepting or rejecting submissions must be separately authorized.", + data: { + taskId, + commands: [ + `taskmarket task submissions ${taskId}`, + `taskmarket task accept ${taskId} --worker `, + `taskmarket task reject-submission ${taskId} --worker `, + ], + requiresUserApproval: true, + }, + }; + }, + }, + ], +}; + +export default { + manifest: { + id: "taskmarket-delegation", + name: "TaskMarket Delegation", + description: "Discover external TaskMarket work and prepare authorized delegation tasks from Franklin workflows.", + version: "0.1.0", + provides: { workflows: ["taskmarket-delegation"] }, + entry: "index.js", + }, + workflows: { + "taskmarket-delegation": () => workflow, + }, +}; diff --git a/docs/examples/taskmarket-plugin/plugin.json b/docs/examples/taskmarket-plugin/plugin.json new file mode 100644 index 0000000..a90b727 --- /dev/null +++ b/docs/examples/taskmarket-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "id": "taskmarket-delegation", + "name": "TaskMarket Delegation", + "description": "Discover external TaskMarket work and prepare authorized delegation tasks from Franklin workflows.", + "version": "0.1.0", + "provides": { + "workflows": ["taskmarket-delegation"] + }, + "entry": "index.js", + "author": "BlockRun community", + "homepage": "https://taskmarket.dev/", + "license": "Apache-2.0", + "franklinVersion": ">=3.38.0" +} diff --git a/test/taskmarket-plugin.local.mjs b/test/taskmarket-plugin.local.mjs new file mode 100644 index 0000000..14526f5 --- /dev/null +++ b/test/taskmarket-plugin.local.mjs @@ -0,0 +1,63 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const PLUGIN_DIR = fileURLToPath(new URL("../docs/examples/taskmarket-plugin/", import.meta.url)); + +test("TaskMarket example plugin has a valid Franklin manifest", () => { + const manifest = JSON.parse(readFileSync(`${PLUGIN_DIR}/plugin.json`, "utf8")); + + assert.equal(manifest.id, "taskmarket-delegation"); + assert.equal(manifest.entry, "index.js"); + assert.deepEqual(manifest.provides.workflows, ["taskmarket-delegation"]); + assert.match(manifest.franklinVersion, /^>=/); +}); + +test("TaskMarket example plugin exposes guarded workflow steps", async () => { + const mod = await import(`${PLUGIN_DIR}/index.js`); + const plugin = mod.default; + const workflow = plugin.workflows["taskmarket-delegation"](); + const stepNames = workflow.steps.map((step) => step.name); + + assert.deepEqual(stepNames, ["discover", "prepare-task", "review-submissions"]); + + const prepare = workflow.steps.find((step) => step.name === "prepare-task"); + const result = await prepare.execute({ + config: { maxTaskBudgetUsdc: 2 }, + input: { + taskSpec: { + title: "Check endpoint health", + description: "Verify which endpoints return JSON.", + acceptanceCriteria: "- Include curl receipts", + rewardUsdc: 1.5, + tags: ["api", "verification"], + }, + }, + }); + + assert.equal(result.data.requiresUserApproval, true); + assert.match(result.data.command, /taskmarket task create/); + assert.match(result.data.command, /--reward 1.5/); + assert.match(result.summary, /explicit user approval/); +}); + +test("TaskMarket review step validates task ids before producing accept commands", async () => { + const mod = await import(`${PLUGIN_DIR}/index.js`); + const workflow = mod.default.workflows["taskmarket-delegation"](); + const review = workflow.steps.find((step) => step.name === "review-submissions"); + + await assert.rejects( + () => review.execute({ config: {}, input: { taskId: "not-a-task" } }), + /0x-prefixed 32-byte/, + ); + + const taskId = `0x${"a".repeat(64)}`; + const result = await review.execute({ config: {}, input: { taskId } }); + assert.equal(result.data.requiresUserApproval, true); + assert.deepEqual(result.data.commands, [ + `taskmarket task submissions ${taskId}`, + `taskmarket task accept ${taskId} --worker `, + `taskmarket task reject-submission ${taskId} --worker `, + ]); +});