Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/examples/taskmarket-plugin/README.md
Original file line number Diff line number Diff line change
@@ -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.
186 changes: 186 additions & 0 deletions docs/examples/taskmarket-plugin/index.js
Original file line number Diff line number Diff line change
@@ -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 <worker-address>`,
`taskmarket task reject-submission ${taskId} --worker <worker-address>`,
],
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,
},
};
14 changes: 14 additions & 0 deletions docs/examples/taskmarket-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
63 changes: 63 additions & 0 deletions test/taskmarket-plugin.local.mjs
Original file line number Diff line number Diff line change
@@ -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 <worker-address>`,
`taskmarket task reject-submission ${taskId} --worker <worker-address>`,
]);
});