@jumboo/create-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/bin/index.js +197 -0
- package/package.json +37 -0
- package/src/networks.js +17 -0
- package/src/scaffold.js +38 -0
- package/templates/default/README.md +67 -0
- package/templates/default/_env.example +55 -0
- package/templates/default/_gitignore +7 -0
- package/templates/default/_package.json +18 -0
- package/templates/default/scripts/register.js +90 -0
- package/templates/default/src/auth.js +116 -0
- package/templates/default/src/chain.js +46 -0
- package/templates/default/src/config.js +71 -0
- package/templates/default/src/github.js +95 -0
- package/templates/default/src/index.js +85 -0
- package/templates/default/src/job.js +234 -0
- package/templates/default/src/solver.js +179 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller authentication + the x402 access gate for POST /solve.
|
|
3
|
+
*
|
|
4
|
+
* Who may trigger /solve:
|
|
5
|
+
* 1. The agent OPERATOR (getAgent(AGENT_ID).operatorWallet) → FREE (Compete path)
|
|
6
|
+
* 2. The TASK CREATOR (tasks(taskId).creator) → Hire path, pays a
|
|
7
|
+
* micro-fee via the HTTP 402 handshake (x402)
|
|
8
|
+
* 3. Anyone else → 403.
|
|
9
|
+
*/
|
|
10
|
+
import { ethers } from "ethers";
|
|
11
|
+
import { config } from "./config.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verifies the caller headers:
|
|
15
|
+
* X-Jumboo-Address: the caller's wallet address
|
|
16
|
+
* X-Jumboo-Signature: EIP-191 personal_sign over the exact string
|
|
17
|
+
* `jumboo-solve:<taskId>`
|
|
18
|
+
* Returns the verified (checksummed) address, or null when auth fails.
|
|
19
|
+
*/
|
|
20
|
+
export function verifyCaller(req, taskId) {
|
|
21
|
+
const address = req.get("X-Jumboo-Address");
|
|
22
|
+
const signature = req.get("X-Jumboo-Signature");
|
|
23
|
+
if (!address || !signature) return null;
|
|
24
|
+
|
|
25
|
+
let recovered;
|
|
26
|
+
try {
|
|
27
|
+
recovered = ethers.verifyMessage(`jumboo-solve:${taskId}`, signature);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (recovered.toLowerCase() !== address.toLowerCase()) return null;
|
|
32
|
+
return recovered;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The authoritative x402 payment requirements for one /solve attempt. */
|
|
36
|
+
export function paymentRequirements({ taskId, operatorWallet }) {
|
|
37
|
+
return {
|
|
38
|
+
x402Version: 1,
|
|
39
|
+
accepts: [
|
|
40
|
+
{
|
|
41
|
+
scheme: "exact",
|
|
42
|
+
network: config.hirePrice.network,
|
|
43
|
+
asset: config.hirePrice.asset,
|
|
44
|
+
amount: config.hirePrice.amount,
|
|
45
|
+
payTo: operatorWallet,
|
|
46
|
+
resource: "/solve",
|
|
47
|
+
description: `Hire agent #${config.agentId} to attempt task ${taskId}`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validates the X-PAYMENT header against the quoted requirements.
|
|
55
|
+
*
|
|
56
|
+
* ============================ TODO — PRODUCTION ============================
|
|
57
|
+
* This DEMO implementation only checks that the caller echoed the quoted
|
|
58
|
+
* terms back. It TRUSTS the header. In production the X-PAYMENT payload must
|
|
59
|
+
* be verified AND settled through an x402 facilitator before any compute is
|
|
60
|
+
* spent. Never ship this trust-the-header shortcut.
|
|
61
|
+
* ===========================================================================
|
|
62
|
+
*/
|
|
63
|
+
export function verifyPayment(req, requirements) {
|
|
64
|
+
const header = req.get("X-PAYMENT");
|
|
65
|
+
if (!header) return { ok: false, reason: "no X-PAYMENT header" };
|
|
66
|
+
|
|
67
|
+
let payment;
|
|
68
|
+
try {
|
|
69
|
+
payment = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
|
|
70
|
+
} catch {
|
|
71
|
+
return { ok: false, reason: "X-PAYMENT is not base64-encoded JSON" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const terms = requirements.accepts[0];
|
|
75
|
+
const mismatches = [];
|
|
76
|
+
if (payment.scheme !== terms.scheme) mismatches.push("scheme");
|
|
77
|
+
if (payment.asset !== terms.asset) mismatches.push("asset");
|
|
78
|
+
if (String(payment.amount) !== String(terms.amount)) mismatches.push("amount");
|
|
79
|
+
if (String(payment.payTo || "").toLowerCase() !== terms.payTo.toLowerCase()) mismatches.push("payTo");
|
|
80
|
+
if (mismatches.length > 0) {
|
|
81
|
+
return { ok: false, reason: `X-PAYMENT does not match quoted terms: ${mismatches.join(", ")}` };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, payment };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The full access decision for POST /solve.
|
|
88
|
+
* Returns { allow: true, path } or { allow: false, status, body }.
|
|
89
|
+
*/
|
|
90
|
+
export function authorizeSolve({ req, caller, agent, task, taskId }) {
|
|
91
|
+
// 1. Operator triggers its own agent → free (Compete).
|
|
92
|
+
if (caller.toLowerCase() === agent.operatorWallet.toLowerCase()) {
|
|
93
|
+
return { allow: true, path: "compete" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Task creator hires this agent → x402 handshake (Hire).
|
|
97
|
+
if (caller.toLowerCase() === task.creator.toLowerCase()) {
|
|
98
|
+
const requirements = paymentRequirements({ taskId, operatorWallet: agent.operatorWallet });
|
|
99
|
+
const payment = verifyPayment(req, requirements);
|
|
100
|
+
if (!payment.ok) {
|
|
101
|
+
return {
|
|
102
|
+
allow: false,
|
|
103
|
+
status: 402,
|
|
104
|
+
body: { error: `Payment required: ${payment.reason}`, ...requirements },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { allow: true, path: "hire" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. Everyone else is refused.
|
|
111
|
+
return {
|
|
112
|
+
allow: false,
|
|
113
|
+
status: 403,
|
|
114
|
+
body: { error: "Only the agent operator (Compete) or the task creator (Hire) may trigger /solve" },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-chain reads: load a task and the agent identity. The protocol-critical
|
|
3
|
+
* writes (signing the marker, claiming the escrow) live in @jumboo/agent-sdk.
|
|
4
|
+
*/
|
|
5
|
+
import { ethers } from "ethers";
|
|
6
|
+
import { config } from "./config.js";
|
|
7
|
+
|
|
8
|
+
export const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
|
9
|
+
|
|
10
|
+
/** The agent's registered hot wallet — signs taskIds for PR markers. */
|
|
11
|
+
export const hotWallet = new ethers.Wallet(config.agentHotKey);
|
|
12
|
+
|
|
13
|
+
// TaskState enum in JumbooTaskRegistry.sol
|
|
14
|
+
export const TaskState = {
|
|
15
|
+
Created: 0n,
|
|
16
|
+
Completed: 1n,
|
|
17
|
+
Cancelled: 2n,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const TASK_REGISTRY_ABI = [
|
|
21
|
+
"function tasks(bytes32) view returns (bytes32 taskId, string repoUrl, string issueId, uint256 rewardAmount, address creator, uint256 agentId, uint64 deadline, uint8 state)",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const IDENTITY_REGISTRY_ABI = [
|
|
25
|
+
"function getAgent(uint256) view returns (address operatorWallet, address agentWallet, string agentURI, bool active)",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const taskRegistry = new ethers.Contract(config.taskRegistryAddress, TASK_REGISTRY_ABI, provider);
|
|
29
|
+
export const identityRegistry = new ethers.Contract(config.identityRegistryAddress, IDENTITY_REGISTRY_ABI, provider);
|
|
30
|
+
|
|
31
|
+
/** Loads a task from the TaskRegistry; returns null when it does not exist. */
|
|
32
|
+
export async function getTask(taskId) {
|
|
33
|
+
const task = await taskRegistry.tasks(taskId);
|
|
34
|
+
if (task.creator === ethers.ZeroAddress) return null;
|
|
35
|
+
return task;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Loads this backend's agent identity; returns null when not registered. */
|
|
39
|
+
export async function getAgent(agentId = config.agentId) {
|
|
40
|
+
try {
|
|
41
|
+
const [operatorWallet, agentWallet, agentURI, active] = await identityRegistry.getAgent(agentId);
|
|
42
|
+
return { operatorWallet, agentWallet, agentURI, active };
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment loading + validation. The .env file sits next to package.json so
|
|
3
|
+
* the agent works from any working directory.
|
|
4
|
+
*/
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { config as loadEnv } from "dotenv";
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
export const ROOT_DIR = path.resolve(__dirname, "..");
|
|
11
|
+
|
|
12
|
+
loadEnv({ path: path.join(ROOT_DIR, ".env") });
|
|
13
|
+
|
|
14
|
+
const SOLVERS = ["echo", "claude", "codex", "opencode", "antigravity", "custom"];
|
|
15
|
+
|
|
16
|
+
function required(name, hint = "") {
|
|
17
|
+
const value = process.env[name];
|
|
18
|
+
if (!value) {
|
|
19
|
+
throw new Error(`Missing required env var ${name}${hint ? ` (${hint})` : ""} — see .env.example`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function intEnv(name, fallback) {
|
|
25
|
+
const raw = process.env[name];
|
|
26
|
+
if (raw === undefined || raw === "") return fallback;
|
|
27
|
+
const n = parseInt(raw, 10);
|
|
28
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`${name} must be a positive integer, got: ${raw}`);
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Optional so `npm start` works right after scaffolding, before you register.
|
|
33
|
+
// Defaults to "0" (unregistered): the server boots and /health responds; you
|
|
34
|
+
// need a real id (from `npm run register`) before the agent can win a task.
|
|
35
|
+
const agentId = process.env.AGENT_ID || "0";
|
|
36
|
+
if (!/^\d+$/.test(agentId)) throw new Error(`AGENT_ID must be a non-negative integer, got: ${agentId}`);
|
|
37
|
+
|
|
38
|
+
const solver = process.env.SOLVER || "echo";
|
|
39
|
+
if (!SOLVERS.includes(solver)) {
|
|
40
|
+
throw new Error(`SOLVER must be one of ${SOLVERS.join(" | ")}, got: ${solver}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const hotKey = required("AGENT_HOT_KEY", "the agent's registered hot wallet private key");
|
|
44
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(hotKey)) throw new Error("AGENT_HOT_KEY must be a 0x-prefixed 32-byte hex private key");
|
|
45
|
+
|
|
46
|
+
export const config = {
|
|
47
|
+
port: intEnv("PORT", 8917),
|
|
48
|
+
rpcUrl: required("RPC_URL"),
|
|
49
|
+
agentId,
|
|
50
|
+
agentHotKey: hotKey,
|
|
51
|
+
agentTxKey: process.env.AGENT_TX_KEY || hotKey,
|
|
52
|
+
githubToken: process.env.GITHUB_TOKEN || "",
|
|
53
|
+
githubUsername: process.env.GITHUB_USERNAME || "jumboo-agent",
|
|
54
|
+
oracleUrl: required("ORACLE_URL", "e.g. https://oracle.jumboo.xyz").replace(/\/+$/, ""),
|
|
55
|
+
taskRegistryAddress: required("TASK_REGISTRY_ADDRESS"),
|
|
56
|
+
identityRegistryAddress: required("IDENTITY_REGISTRY_ADDRESS"),
|
|
57
|
+
validationRegistryAddress: required("VALIDATION_REGISTRY_ADDRESS"),
|
|
58
|
+
workdir: path.resolve(ROOT_DIR, process.env.WORKDIR || "./workspace"),
|
|
59
|
+
solver,
|
|
60
|
+
// Optional CLI-agent overrides (see src/solver.js). Blank/null = use the preset.
|
|
61
|
+
solverCommand: process.env.SOLVER_COMMAND || "",
|
|
62
|
+
solverArgs: process.env.SOLVER_ARGS ? process.env.SOLVER_ARGS.split(" ").filter(Boolean) : null,
|
|
63
|
+
solverPromptVia: process.env.SOLVER_PROMPT_VIA || "",
|
|
64
|
+
hirePrice: {
|
|
65
|
+
amount: process.env.HIRE_PRICE_AMOUNT || "0.50",
|
|
66
|
+
asset: process.env.HIRE_PRICE_ASSET || "USDC",
|
|
67
|
+
network: process.env.HIRE_PRICE_NETWORK || "sepolia",
|
|
68
|
+
},
|
|
69
|
+
dryRun: process.env.DRY_RUN === "1",
|
|
70
|
+
attestationPollSec: intEnv("ATTESTATION_POLL_SEC", 60),
|
|
71
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal GitHub REST helpers over plain fetch — no SDK dependency.
|
|
3
|
+
* Also owns repo-URL parsing, which tolerates non-GitHub URLs (file://
|
|
4
|
+
* remotes are used in local DRY_RUN testing: no issue fetch, no fork logic).
|
|
5
|
+
*/
|
|
6
|
+
import { config } from "./config.js";
|
|
7
|
+
|
|
8
|
+
const API = "https://api.github.com";
|
|
9
|
+
|
|
10
|
+
/** Parses a task repoUrl into { isGitHub, owner, repo, cloneUrl }. */
|
|
11
|
+
export function parseRepoUrl(repoUrl) {
|
|
12
|
+
const url = (repoUrl || "").trim();
|
|
13
|
+
|
|
14
|
+
const ssh = url.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
15
|
+
if (ssh) return { isGitHub: true, owner: ssh[1], repo: ssh[2], cloneUrl: url };
|
|
16
|
+
|
|
17
|
+
const https = url.match(/^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
18
|
+
if (https) return { isGitHub: true, owner: https[1], repo: https[2], cloneUrl: url };
|
|
19
|
+
|
|
20
|
+
// file:// or any non-GitHub remote — clone/push directly, skip GitHub API.
|
|
21
|
+
return { isGitHub: false, owner: null, repo: null, cloneUrl: url };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Clone URL with the token embedded, so `git clone`/`git push` authenticate. */
|
|
25
|
+
export function authenticatedCloneUrl(parsed) {
|
|
26
|
+
if (!parsed.isGitHub || !config.githubToken) return parsed.cloneUrl;
|
|
27
|
+
return `https://x-access-token:${config.githubToken}@github.com/${parsed.owner}/${parsed.repo}.git`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function ghFetch(path, { method = "GET", body } = {}) {
|
|
31
|
+
const res = await fetch(`${API}${path}`, {
|
|
32
|
+
method,
|
|
33
|
+
headers: {
|
|
34
|
+
Authorization: `Bearer ${config.githubToken}`,
|
|
35
|
+
Accept: "application/vnd.github+json",
|
|
36
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
37
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
38
|
+
},
|
|
39
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
40
|
+
});
|
|
41
|
+
const data = await res.json().catch(() => ({}));
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const details = data.errors ? ` — ${JSON.stringify(data.errors)}` : "";
|
|
44
|
+
throw new Error(`GitHub API ${res.status} ${method} ${path}: ${data.message || "request failed"}${details}`);
|
|
45
|
+
}
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fetch an issue's { title, body }; returns null on any failure (best-effort). */
|
|
50
|
+
export async function getIssue(owner, repo, number) {
|
|
51
|
+
try {
|
|
52
|
+
const issue = await ghFetch(`/repos/${owner}/${repo}/issues/${number}`);
|
|
53
|
+
return { number, title: issue.title || "", body: issue.body || "" };
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Default branch of a repo; falls back to "main". */
|
|
60
|
+
export async function getDefaultBranch(owner, repo) {
|
|
61
|
+
try {
|
|
62
|
+
const data = await ghFetch(`/repos/${owner}/${repo}`);
|
|
63
|
+
return data.default_branch || "main";
|
|
64
|
+
} catch {
|
|
65
|
+
return "main";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fork the repo into the machine user's account (used when a direct push to
|
|
71
|
+
* origin is rejected). Fork creation is async — poll until it exists.
|
|
72
|
+
*/
|
|
73
|
+
export async function createFork(owner, repo) {
|
|
74
|
+
const fork = await ghFetch(`/repos/${owner}/${repo}/forks`, { method: "POST" });
|
|
75
|
+
const forkOwner = fork.owner?.login || config.githubUsername;
|
|
76
|
+
const forkRepo = fork.name || repo;
|
|
77
|
+
for (let i = 0; i < 10; i++) {
|
|
78
|
+
try {
|
|
79
|
+
await ghFetch(`/repos/${forkOwner}/${forkRepo}`);
|
|
80
|
+
break;
|
|
81
|
+
} catch {
|
|
82
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { owner: forkOwner, repo: forkRepo };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Open a pull request; head may be "branch" or "forkOwner:branch". */
|
|
89
|
+
export async function createPullRequest(owner, repo, { title, head, base, body }) {
|
|
90
|
+
const pr = await ghFetch(`/repos/${owner}/${repo}/pulls`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
body: { title, head, base, body },
|
|
93
|
+
});
|
|
94
|
+
return { number: pr.number, url: pr.html_url };
|
|
95
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* __PROJECT_NAME__ — a Jumboo agent backend.
|
|
3
|
+
*
|
|
4
|
+
* Receives POST /solve, enforces the Compete/Hire (x402) access policy, then
|
|
5
|
+
* runs the full pipeline: clone repo → solve issue → open PR with the Jumboo
|
|
6
|
+
* marker block → poll the oracle → auto-claim the escrow (via @jumboo/agent-sdk).
|
|
7
|
+
*/
|
|
8
|
+
import express from "express";
|
|
9
|
+
import { config } from "./config.js";
|
|
10
|
+
import { hotWallet, getAgent, getTask, TaskState } from "./chain.js";
|
|
11
|
+
import { verifyCaller, authorizeSolve } from "./auth.js";
|
|
12
|
+
import { startJob, getJob, jobView, jobCount } from "./job.js";
|
|
13
|
+
|
|
14
|
+
const app = express();
|
|
15
|
+
app.use(express.json());
|
|
16
|
+
|
|
17
|
+
app.get("/health", (_req, res) => {
|
|
18
|
+
res.json({
|
|
19
|
+
ok: true,
|
|
20
|
+
agentId: config.agentId,
|
|
21
|
+
wallet: hotWallet.address,
|
|
22
|
+
solver: config.solver,
|
|
23
|
+
dryRun: config.dryRun,
|
|
24
|
+
jobs: jobCount(),
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
app.post("/solve", async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const taskId = req.body?.taskId;
|
|
31
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(taskId || "")) {
|
|
32
|
+
return res.status(400).json({ error: "body must be { taskId: '0x<64 hex>' }" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const caller = verifyCaller(req, taskId);
|
|
36
|
+
if (!caller) {
|
|
37
|
+
return res.status(401).json({
|
|
38
|
+
error:
|
|
39
|
+
"invalid caller auth: send X-Jumboo-Address and X-Jumboo-Signature " +
|
|
40
|
+
`(personal_sign of the string "jumboo-solve:${taskId}")`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const agent = await getAgent(config.agentId);
|
|
45
|
+
if (!agent) {
|
|
46
|
+
return res.status(500).json({ error: `agent #${config.agentId} is not registered on-chain` });
|
|
47
|
+
}
|
|
48
|
+
if (!agent.active) {
|
|
49
|
+
return res.status(409).json({ error: `agent #${config.agentId} is inactive` });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const task = await getTask(taskId);
|
|
53
|
+
if (!task) {
|
|
54
|
+
return res.status(404).json({ error: "task not found on-chain" });
|
|
55
|
+
}
|
|
56
|
+
if (task.state !== TaskState.Created) {
|
|
57
|
+
return res.status(409).json({ error: `task is not open (state=${task.state})` });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const decision = authorizeSolve({ req, caller, agent, task, taskId });
|
|
61
|
+
if (!decision.allow) {
|
|
62
|
+
return res.status(decision.status).json(decision.body);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const job = startJob({ taskId, task, caller, path: decision.path });
|
|
66
|
+
return res.status(202).json({ jobId: job.id, status: "queued" });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(`[solve] ${err.stack || err.message}`);
|
|
69
|
+
return res.status(500).json({ error: err.message });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.get("/jobs/:id", (req, res) => {
|
|
74
|
+
const job = getJob(req.params.id);
|
|
75
|
+
if (!job) return res.status(404).json({ error: "job not found" });
|
|
76
|
+
res.json(jobView(job));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
app.listen(config.port, () => {
|
|
80
|
+
console.log(`__PROJECT_NAME__ listening on :${config.port}`);
|
|
81
|
+
console.log(` agent id: #${config.agentId}`);
|
|
82
|
+
console.log(` hot wallet: ${hotWallet.address}`);
|
|
83
|
+
console.log(` solver: ${config.solver}${config.dryRun ? " (DRY_RUN)" : ""}`);
|
|
84
|
+
console.log(` oracle: ${config.oracleUrl}`);
|
|
85
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The async job pipeline behind POST /solve:
|
|
3
|
+
*
|
|
4
|
+
* queued → cloning → solving → pushing → opening-pr → awaiting-merge
|
|
5
|
+
* → claiming → done | failed:<step>
|
|
6
|
+
*
|
|
7
|
+
* The protocol-critical steps use @jumboo/agent-sdk:
|
|
8
|
+
* - buildMarker: sign the taskId + build the PR marker block
|
|
9
|
+
* - claim: poll the oracle for the attestation, then submit it on-chain
|
|
10
|
+
*
|
|
11
|
+
* DRY_RUN=1 stops after the push: the PR is not opened, the marker block is
|
|
12
|
+
* logged instead, and attestation polling/claiming is skipped.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { mkdir } from "node:fs/promises";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { ethers } from "ethers";
|
|
19
|
+
import { buildMarker, claim, OUTCOME_NAME } from "@jumboo/agent-sdk";
|
|
20
|
+
import { config } from "./config.js";
|
|
21
|
+
import { provider, hotWallet } from "./chain.js";
|
|
22
|
+
import { getSolver } from "./solver.js";
|
|
23
|
+
import {
|
|
24
|
+
parseRepoUrl,
|
|
25
|
+
authenticatedCloneUrl,
|
|
26
|
+
getIssue,
|
|
27
|
+
getDefaultBranch,
|
|
28
|
+
createFork,
|
|
29
|
+
createPullRequest,
|
|
30
|
+
} from "./github.js";
|
|
31
|
+
|
|
32
|
+
const jobs = new Map();
|
|
33
|
+
|
|
34
|
+
/** Run a child process, resolving stdout or rejecting with combined output. */
|
|
35
|
+
function run(cmd, args, { cwd } = {}) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const child = spawn(cmd, args, { cwd, windowsHide: true });
|
|
38
|
+
let out = "";
|
|
39
|
+
let err = "";
|
|
40
|
+
child.stdout.on("data", (d) => (out += d));
|
|
41
|
+
child.stderr.on("data", (d) => (err += d));
|
|
42
|
+
child.on("error", (e) => reject(new Error(`${cmd} failed to start: ${e.message}`)));
|
|
43
|
+
child.on("close", (code) => {
|
|
44
|
+
if (code !== 0) {
|
|
45
|
+
reject(new Error(`${cmd} ${args[0]} exited ${code}: ${(err || out).trim().slice(0, 2000)}`));
|
|
46
|
+
} else {
|
|
47
|
+
resolve(out);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const git = (args, opts) => run("git", args, opts);
|
|
54
|
+
|
|
55
|
+
function setStatus(job, status, detail) {
|
|
56
|
+
job.status = status;
|
|
57
|
+
job.steps.push({ status, at: new Date().toISOString(), ...(detail ? { detail } : {}) });
|
|
58
|
+
console.log(`[job ${job.id}] ${status}${detail ? ` — ${detail}` : ""}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getJob(id) {
|
|
62
|
+
return jobs.get(id) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function jobCount() {
|
|
66
|
+
return jobs.size;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Public JSON view of a job (no secrets are ever stored on the job object). */
|
|
70
|
+
export function jobView(job) {
|
|
71
|
+
return {
|
|
72
|
+
jobId: job.id,
|
|
73
|
+
taskId: job.taskId,
|
|
74
|
+
path: job.path,
|
|
75
|
+
caller: job.caller,
|
|
76
|
+
solver: job.solver,
|
|
77
|
+
status: job.status,
|
|
78
|
+
steps: job.steps,
|
|
79
|
+
branch: job.branch ?? null,
|
|
80
|
+
prUrl: job.prUrl ?? null,
|
|
81
|
+
markerBlock: job.markerBlock ?? null,
|
|
82
|
+
outcome: job.outcome ?? null,
|
|
83
|
+
claimTx: job.claimTx ?? null,
|
|
84
|
+
dryRun: job.dryRun ?? false,
|
|
85
|
+
error: job.error ?? null,
|
|
86
|
+
createdAt: job.createdAt,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Registers a queued job and kicks off the async pipeline. */
|
|
91
|
+
export function startJob({ taskId, task, caller, path: accessPath }) {
|
|
92
|
+
const job = {
|
|
93
|
+
id: randomBytes(8).toString("hex"),
|
|
94
|
+
taskId,
|
|
95
|
+
task,
|
|
96
|
+
caller,
|
|
97
|
+
path: accessPath, // "compete" | "hire"
|
|
98
|
+
solver: config.solver,
|
|
99
|
+
status: "queued",
|
|
100
|
+
steps: [],
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
jobs.set(job.id, job);
|
|
104
|
+
setStatus(job, "queued", `${accessPath} path, caller ${caller}`);
|
|
105
|
+
setImmediate(() =>
|
|
106
|
+
runJob(job).catch((err) => {
|
|
107
|
+
job.error = job.error || err.message;
|
|
108
|
+
if (!job.status.startsWith("failed")) job.status = "failed:unknown";
|
|
109
|
+
console.error(`[job ${job.id}] pipeline crashed: ${err.message}`);
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
return job;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runJob(job) {
|
|
116
|
+
const { task, taskId } = job;
|
|
117
|
+
const parsed = parseRepoUrl(task.repoUrl);
|
|
118
|
+
const repoDir = path.join(config.workdir, job.id);
|
|
119
|
+
const branch = `jumboo/task-${taskId.slice(2, 10)}`;
|
|
120
|
+
job.branch = branch;
|
|
121
|
+
let step = "cloning";
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// 1+2. Clone the task repo and create the working branch --------------
|
|
125
|
+
setStatus(job, "cloning", `${task.repoUrl} → ${repoDir}`);
|
|
126
|
+
await mkdir(config.workdir, { recursive: true });
|
|
127
|
+
await git(["clone", authenticatedCloneUrl(parsed), repoDir]);
|
|
128
|
+
await git(["checkout", "-b", branch], { cwd: repoDir });
|
|
129
|
+
|
|
130
|
+
// 3. Solve -------------------------------------------------------------
|
|
131
|
+
step = "solving";
|
|
132
|
+
const issue = await resolveIssue(parsed, task.issueId);
|
|
133
|
+
setStatus(job, "solving", `driver=${config.solver}, issue=${issue.ref}`);
|
|
134
|
+
const solver = getSolver();
|
|
135
|
+
const { summary } = await solver.solve({ repoDir, task, issue });
|
|
136
|
+
job.summary = summary;
|
|
137
|
+
|
|
138
|
+
// 4. Commit as the machine user, push (fork fallback) -------------------
|
|
139
|
+
step = "pushing";
|
|
140
|
+
setStatus(job, "pushing", branch);
|
|
141
|
+
await git(["add", "-A"], { cwd: repoDir });
|
|
142
|
+
const changed = await git(["status", "--porcelain"], { cwd: repoDir });
|
|
143
|
+
if (!changed.trim()) {
|
|
144
|
+
throw new Error("solver produced no changes — nothing to commit");
|
|
145
|
+
}
|
|
146
|
+
const user = config.githubUsername;
|
|
147
|
+
await git(
|
|
148
|
+
[
|
|
149
|
+
"-c", `user.name=${user}`,
|
|
150
|
+
"-c", `user.email=${user}@users.noreply.github.com`,
|
|
151
|
+
"commit", "-m", `Jumboo task ${taskId.slice(0, 10)}… (agent #${config.agentId})`,
|
|
152
|
+
],
|
|
153
|
+
{ cwd: repoDir }
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
let prHead = branch;
|
|
157
|
+
try {
|
|
158
|
+
await git(["push", "-u", "origin", branch], { cwd: repoDir });
|
|
159
|
+
} catch (pushErr) {
|
|
160
|
+
// No write access to origin → fork-and-push (GitHub repos only).
|
|
161
|
+
if (!parsed.isGitHub || !config.githubToken) throw pushErr;
|
|
162
|
+
setStatus(job, "pushing", `origin push rejected, forking as ${user}`);
|
|
163
|
+
const fork = await createFork(parsed.owner, parsed.repo);
|
|
164
|
+
const forkUrl = `https://x-access-token:${config.githubToken}@github.com/${fork.owner}/${fork.repo}.git`;
|
|
165
|
+
await git(["remote", "add", "fork", forkUrl], { cwd: repoDir });
|
|
166
|
+
await git(["push", "-u", "fork", branch], { cwd: repoDir });
|
|
167
|
+
prHead = `${fork.owner}:${branch}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 5. Build the marker block (SDK signs the RAW taskId bytes for us) ------
|
|
171
|
+
const { marker } = await buildMarker({ taskId, agentId: config.agentId, hotWallet });
|
|
172
|
+
job.markerBlock = marker;
|
|
173
|
+
|
|
174
|
+
// 6. Open the PR (or log the markers in DRY_RUN) -------------------------
|
|
175
|
+
if (config.dryRun) {
|
|
176
|
+
job.dryRun = true;
|
|
177
|
+
console.log(`[job ${job.id}] DRY_RUN — skipping PR creation. Branch: ${prHead}`);
|
|
178
|
+
console.log(`[job ${job.id}] PR marker block:\n${marker}`);
|
|
179
|
+
setStatus(job, "done", `dry run: branch ${prHead} pushed, PR skipped`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
step = "opening-pr";
|
|
184
|
+
setStatus(job, "opening-pr", `head=${prHead}`);
|
|
185
|
+
if (!parsed.isGitHub) {
|
|
186
|
+
throw new Error("cannot open a PR on a non-GitHub repo (use DRY_RUN=1 for local remotes)");
|
|
187
|
+
}
|
|
188
|
+
const base = await getDefaultBranch(parsed.owner, parsed.repo);
|
|
189
|
+
const pr = await createPullRequest(parsed.owner, parsed.repo, {
|
|
190
|
+
title: `Jumboo task ${taskId.slice(0, 10)}… (agent #${config.agentId})`,
|
|
191
|
+
head: prHead,
|
|
192
|
+
base,
|
|
193
|
+
body: `${(job.summary || "").trim()}\n\n---\n${marker}`,
|
|
194
|
+
});
|
|
195
|
+
job.prUrl = pr.url;
|
|
196
|
+
console.log(`[job ${job.id}] PR opened: ${pr.url}`);
|
|
197
|
+
|
|
198
|
+
// 7. Wait for the oracle's attestation, then claim on-chain (SDK) --------
|
|
199
|
+
step = "awaiting-merge";
|
|
200
|
+
setStatus(job, "awaiting-merge", `polling ${config.oracleUrl} every ${config.attestationPollSec}s`);
|
|
201
|
+
const txWallet = new ethers.Wallet(config.agentTxKey, provider); // pays claim gas
|
|
202
|
+
const { attestation, receipt } = await claim({
|
|
203
|
+
oracleUrl: config.oracleUrl,
|
|
204
|
+
registryAddress: config.validationRegistryAddress,
|
|
205
|
+
taskId,
|
|
206
|
+
agentId: config.agentId,
|
|
207
|
+
wallet: txWallet,
|
|
208
|
+
intervalMs: config.attestationPollSec * 1000,
|
|
209
|
+
onPoll: ({ error }) => {
|
|
210
|
+
if (error) console.warn(`[job ${job.id}] poll: ${error.message}`);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
job.outcome = OUTCOME_NAME[Number(attestation.outcome)] ?? String(attestation.outcome);
|
|
214
|
+
|
|
215
|
+
step = "claiming";
|
|
216
|
+
setStatus(job, "claiming", `outcome=${job.outcome}`);
|
|
217
|
+
job.claimTx = receipt.hash;
|
|
218
|
+
setStatus(job, "done", `outcome=${job.outcome}, tx=${receipt.hash}`);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
job.error = err.message;
|
|
221
|
+
setStatus(job, `failed:${step}`, err.message);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Best-effort issue content for the solver prompt. */
|
|
226
|
+
async function resolveIssue(parsed, issueId) {
|
|
227
|
+
const fallback = { title: "", body: "", ref: String(issueId) };
|
|
228
|
+
if (!parsed.isGitHub || !config.githubToken) return fallback;
|
|
229
|
+
const match = String(issueId).match(/\d+/);
|
|
230
|
+
if (!match) return fallback;
|
|
231
|
+
const issue = await getIssue(parsed.owner, parsed.repo, Number(match[0]));
|
|
232
|
+
if (!issue) return fallback;
|
|
233
|
+
return { ...issue, ref: `#${issue.number} ${issue.title}`.trim() };
|
|
234
|
+
}
|