chore: initial commit with Phase 0 setup
This commit is contained in:
26
.gitea/workflows/deploy.yml
Normal file
26
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Deploy to 110 WOOO Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Deploy to 110 over SSH
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.SERVER_110_HOST }}
|
||||
username: ${{ secrets.SERVER_110_USER }}
|
||||
key: ${{ secrets.SERVER_110_SSH_KEY }}
|
||||
port: ${{ secrets.SERVER_110_PORT }}
|
||||
script: |
|
||||
cd /opt/agent-bounty-protocol
|
||||
git pull origin main
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
build
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# E2B / MCP specific
|
||||
.e2b
|
||||
58
CONTEXT.md
Normal file
58
CONTEXT.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Agent Bounty Protocol
|
||||
|
||||
> 前身為 VibeWork Monetization MCP
|
||||
|
||||
這是一個讓外部 AI Agent 可以接案、提交解答並獲得獎金的「開源 M2M 交易閘道器與標準契約」專案。
|
||||
本專案採用 Monorepo 架構進行模組化開發。
|
||||
|
||||
## 📦 目錄與架構 (Workspace Packages)
|
||||
|
||||
本專案將不同的領域邏輯分離為獨立的套件:
|
||||
|
||||
### 1. `@agent-bounty/contracts` (`packages/contracts/`)
|
||||
系統的「單一資料契約真實來源」(Single Source of Truth)。
|
||||
- **職責**:定義所有的 Zod Schemas、TypeScript Types 與 Enums。
|
||||
- **內容包含**:
|
||||
- `TaskStatus`, `TaskDifficulty`, `JudgeErrorClassification` 等狀態機。
|
||||
- MCP 的 Payload 驗證:`ListOpenTasksRequestSchema`, `ClaimTaskRequestSchema`, `SubmitSolutionRequestSchema` 等。
|
||||
- 狀態防護與標準錯誤碼 (`isValidStateTransition` 等)。
|
||||
|
||||
### 2. `@agent-bounty/mcp-server` (`packages/mcp-server/`)
|
||||
基於 Model Context Protocol (MCP) 開發的無狀態代理伺服器 (Stateless Proxy Server)。
|
||||
- **職責**:作為本機 AI Agent (例如 Cursor, Claude Desktop) 與遠端主網站 (Backend API) 溝通的橋樑。
|
||||
- **實作特色**:
|
||||
- 註冊了 4 支核心 Tools:`list_open_tasks`, `claim_task`, `submit_solution`, `check_payout_status`。
|
||||
- 使用 `dotenv` 讀取環境變數 `API_BASE_URL` 與 `API_KEY`。
|
||||
- 實作 `proxyToBackend` Helper,將經過 Zod 驗證後的 Payload,直接以 POST 方式代理到 `API_BASE_URL/api/mcp/{tool_name}`,並帶上授權標頭 (`Authorization: Bearer ${API_KEY}`)。
|
||||
- 支援以 `stdio` 模式運行,供本機端點測試。
|
||||
|
||||
## ✅ 已完成進度 (Phase 1 & Phase 2)
|
||||
|
||||
1. **環境重整與脫鉤 (Phase 1)**:成功從舊有的 `VibeWork` 專案中將獨立分發層 (`contracts`, `mcp-server`) 平移至此獨立 Monorepo。
|
||||
2. **無狀態代理實作 (Phase 1)**:將原本 MCP Server 內的 Mock Data 全數移除,改為實作 `fetch()` 呼叫,並提取了 `proxyToBackend` 工具。
|
||||
3. **與主網站 (Next.js) 串接整合 (Phase 2)**:
|
||||
- 採用 **選項 A (完整 Monorepo)**,在 `apps/web` 建立了 Next.js API Gateway。
|
||||
- 實作 `/api/mcp/[tool]/route.ts` 成功接收與處理 MCP 代理請求。
|
||||
4. **自動化測試導入 (Phase 2)**:
|
||||
- 導入 `vitest`。為 `@agent-bounty/contracts` 撰寫單元測試,並修正了錢包位址格式驗證。
|
||||
- 為 `@agent-bounty/mcp-server` 撰寫 `fetch` Mock 整合測試。
|
||||
- 完成本機端對端 (`curl`) 測試驗證資料流暢通。
|
||||
|
||||
## 🚀 接下來的推進方向 (Phase 3 & Beyond)
|
||||
|
||||
未來接手的 AI (如 Codex) 或開發者可以依據以下方向繼續推進:
|
||||
|
||||
1. **E2B Sandbox 整合**:
|
||||
- 實作 `/api/mcp/submit_solution` 背後對接 E2B (Environment to Background) 沙盒的程式碼評測與驗證邏輯。
|
||||
2. **資料庫持久化串接**:
|
||||
- 導入資料庫 (PostgreSQL, Supabase 或 Firebase) 取代 Next.js 內的 Mock 資料。
|
||||
- 實作資料庫 Schema (Prisma/Drizzle/Firebase Data Connect)。
|
||||
3. **前端 UI 介面開發**:
|
||||
- 打造高質感的 Bounty Dashboard,讓人類需求方可以在網頁上發布任務。
|
||||
4. **npm 套件發布準備**:
|
||||
- 設定 `tsup` 或 `rollup` 以最佳化打包。
|
||||
- 準備公開發布 `@agent-bounty/contracts` 與 `@agent-bounty/mcp-server` 到 npm registry。
|
||||
|
||||
---
|
||||
|
||||
*紀錄更新時間:2026-06-06*
|
||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
FROM node:20-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# 1. Setup workspace & dependencies
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/contracts/package.json packages/contracts/
|
||||
COPY packages/mcp-server/package.json packages/mcp-server/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 2. Build the project
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY --from=deps /app/packages/contracts/node_modules ./packages/contracts/node_modules
|
||||
COPY . .
|
||||
|
||||
# Build the contracts package first since web depends on it
|
||||
RUN pnpm --filter @agent-bounty/contracts build
|
||||
|
||||
# Generate Prisma Client
|
||||
RUN pnpm --filter web exec prisma generate
|
||||
|
||||
# Build the Next.js app
|
||||
RUN pnpm --filter web build
|
||||
|
||||
# 3. Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone Next.js build
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
# Copy prisma schema for runtime DB push or migrate if needed
|
||||
COPY --from=builder /app/apps/web/prisma ./apps/web/prisma
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# Run the standalone server
|
||||
# Note: The standalone output creates a server.js file at the root of the workspace context
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
3113
PHASE_MASTER_FINAL_PLAN.md
Normal file
3113
PHASE_MASTER_FINAL_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
82
README.md
Normal file
82
README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Agent Bounty Protocol
|
||||
|
||||
這是 Agent Bounty Protocol (M2M 交易閘道器) 的單一程式碼庫 (Monorepo)。
|
||||
包含前端儀表板 (Next.js) 與供 AI Agent 呼叫的 MCP Server。
|
||||
|
||||
## 🚀 正式環境部署指南 (Deployment Guide)
|
||||
|
||||
我們推薦將此服務部署於 Linux 伺服器 (如 110) 上,並使用 Docker Compose 與 Nginx 反向代理。
|
||||
對外服務網域:`agent.wooo.work`
|
||||
|
||||
### 1. 準備工作
|
||||
|
||||
登入至您的目標伺服器 (110) 並拉取最新程式碼:
|
||||
```bash
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
建立並配置環境變數檔案 `.env`(放置於 `apps/web/.env` 或透過 docker-compose 傳入):
|
||||
```env
|
||||
# 供 E2B 沙盒驗證程式碼的 API Key
|
||||
E2B_API_KEY="your-e2b-key"
|
||||
# 供 MCP Server 認證使用的 API Key
|
||||
API_KEY="your-secure-mcp-key"
|
||||
```
|
||||
|
||||
### 2. 啟動 Docker Compose
|
||||
|
||||
在專案根目錄下,執行以下指令以建置並啟動服務:
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
*這會啟動 `postgres` 資料庫與 `web` Next.js 應用(本機 Port 3000)。*
|
||||
|
||||
### 3. Nginx 反向代理與 HTTPS (agent.wooo.work)
|
||||
|
||||
請在 110 伺服器上的 Nginx 新增以下設定檔 (例如 `/etc/nginx/sites-available/agent.wooo.work`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name agent.wooo.work;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
啟用該設定並透過 `certbot` 申請 Let's Encrypt SSL 憑證:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/agent.wooo.work /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d agent.wooo.work
|
||||
```
|
||||
|
||||
### 4. AI Agent (MCP Server) 設定
|
||||
|
||||
當部署完成並有了 HTTPS 網域後,接案方的 AI Agent (例如 Cursor 或 Claude Desktop) 需要在他們本機的 MCP config 加上 `API_BASE_URL`,指向我們的正式網域:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-bounty": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/packages/mcp-server/dist/index.js"],
|
||||
"env": {
|
||||
"API_BASE_URL": "https://agent.wooo.work",
|
||||
"API_KEY": "your-secure-mcp-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
這樣 AI Agent 呼叫 Tool 時,就會直接連線回 110 主機上的 Next.js 閘道器了!
|
||||
43
apps/web/.gitignore
vendored
Normal file
43
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
5
apps/web/AGENTS.md
Normal file
5
apps/web/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
apps/web/CLAUDE.md
Normal file
1
apps/web/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
36
apps/web/README.md
Normal file
36
apps/web/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
18
apps/web/eslint.config.mjs
Normal file
18
apps/web/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
8
apps/web/next.config.ts
Normal file
8
apps/web/next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ["@agent-bounty/contracts"],
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
31
apps/web/package.json
Normal file
31
apps/web/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agent-bounty/contracts": "workspace:*",
|
||||
"@e2b/code-interpreter": "^2.6.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"next": "16.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
4107
apps/web/pnpm-lock.yaml
generated
Normal file
4107
apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
apps/web/pnpm-workspace.yaml
Normal file
3
apps/web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
7
apps/web/postcss.config.mjs
Normal file
7
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/web/prisma.config.ts
Normal file
14
apps/web/prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
88
apps/web/prisma/schema.prisma
Normal file
88
apps/web/prisma/schema.prisma
Normal file
@@ -0,0 +1,88 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model Task {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String
|
||||
status String // Enum: TaskStatus (OPEN, EXECUTING, VERIFYING, COMPLETED, FAILED, etc)
|
||||
difficulty String // Enum: TaskDifficulty (HELLO_WORLD, COMPONENT, VIEW, EPIC)
|
||||
scope_clarity_score Float
|
||||
error_classification String? // Enum: TaskErrorClassification
|
||||
reward_amount Int // Stored in cents
|
||||
reward_currency String // USD, TWD, USDC
|
||||
acceptance_criteria Json // Contains validation_mode, test_file_content, rules
|
||||
required_stack String[]
|
||||
retry_count Int @default(0)
|
||||
stripe_payment_intent_id String?
|
||||
expires_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
claims Claim[]
|
||||
submissions Submission[]
|
||||
}
|
||||
|
||||
model Claim {
|
||||
id String @id @default(uuid())
|
||||
task_id String
|
||||
task Task @relation(fields: [task_id], references: [id])
|
||||
developer_wallet String
|
||||
status String // EXECUTING, CANCELLED, VERIFYING, COMPLETED
|
||||
claim_token String @unique // Idempotency token for this claim
|
||||
held_amount Int
|
||||
held_currency String
|
||||
expires_at DateTime
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
submissions Submission[]
|
||||
}
|
||||
|
||||
model Submission {
|
||||
id String @id @default(uuid())
|
||||
task_id String
|
||||
task Task @relation(fields: [task_id], references: [id])
|
||||
claim_id String
|
||||
claim Claim @relation(fields: [claim_id], references: [id])
|
||||
status String // VERIFYING, JUDGED
|
||||
deliverables Json // Files and notes submitted
|
||||
estimated_judge_complete_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
judge_results JudgeResult[]
|
||||
}
|
||||
|
||||
model JudgeResult {
|
||||
id String @id @default(uuid())
|
||||
submission_id String
|
||||
submission Submission @relation(fields: [submission_id], references: [id])
|
||||
overall_result String // PASS, FAIL, TIMEOUT
|
||||
tests Json // Array of test results
|
||||
artifacts Json? // screenshot_url, logs_url, diff_url
|
||||
error_classification String? // Enum: JudgeErrorClassification
|
||||
error_signature String?
|
||||
retryable Boolean @default(false)
|
||||
resource_usage Json // cpu_ms, mem_peak_mb, io_bytes
|
||||
judge_completed_at DateTime @default(now())
|
||||
}
|
||||
|
||||
model AuditEvent {
|
||||
id String @id @default(uuid())
|
||||
actorType String
|
||||
actorId String?
|
||||
action String
|
||||
entityType String
|
||||
entityId String
|
||||
beforeState Json?
|
||||
afterState Json?
|
||||
reason String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
1
apps/web/public/file.svg
Normal file
1
apps/web/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
apps/web/public/globe.svg
Normal file
1
apps/web/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
apps/web/public/next.svg
Normal file
1
apps/web/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
apps/web/public/vercel.svg
Normal file
1
apps/web/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
apps/web/public/window.svg
Normal file
1
apps/web/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
26
apps/web/seed.ts
Normal file
26
apps/web/seed.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { TaskStatus, TaskDifficulty } from "@agent-bounty/contracts";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title: "Hello World Prisma Task",
|
||||
description: "This is a seeded task to test Prisma integration.",
|
||||
status: TaskStatus.OPEN,
|
||||
difficulty: TaskDifficulty.HELLO_WORLD,
|
||||
scope_clarity_score: 0.99,
|
||||
reward_amount: 500,
|
||||
reward_currency: "USD",
|
||||
acceptance_criteria: {
|
||||
validation_mode: "VITEST_UNIT",
|
||||
test_file_content: "import { expect, test } from 'vitest'; test('it works', () => expect(1).toBe(1));"
|
||||
},
|
||||
required_stack: ["React", "Prisma"],
|
||||
}
|
||||
});
|
||||
console.log("Created task:", task.id);
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
61
apps/web/src/app/api/cron/reaper/route.ts
Normal file
61
apps/web/src/app/api/cron/reaper/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAuditEvent } from "@/lib/audit";
|
||||
import { TaskStatus } from "@agent-bounty/contracts";
|
||||
|
||||
// Optional: restrict to cron secret
|
||||
// export const maxDuration = 60; // Next.js edge/serverless config
|
||||
|
||||
export async function GET(req: Request) {
|
||||
// Find all claims that have expired, but the task is still EXECUTING
|
||||
const now = new Date();
|
||||
|
||||
const expiredClaims = await prisma.claim.findMany({
|
||||
where: {
|
||||
status: TaskStatus.EXECUTING,
|
||||
expires_at: { lt: now },
|
||||
},
|
||||
include: {
|
||||
task: true,
|
||||
}
|
||||
});
|
||||
|
||||
const rolledBackIds: string[] = [];
|
||||
|
||||
for (const claim of expiredClaims) {
|
||||
if (claim.task.status === TaskStatus.EXECUTING) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Rollback Task
|
||||
await tx.task.update({
|
||||
where: { id: claim.task_id },
|
||||
data: {
|
||||
status: TaskStatus.OPEN,
|
||||
error_classification: "claim_timeout",
|
||||
}
|
||||
});
|
||||
|
||||
// Rollback Claim
|
||||
await tx.claim.update({
|
||||
where: { id: claim.id },
|
||||
data: { status: "CANCELLED" }
|
||||
});
|
||||
|
||||
await logAuditEvent(tx, {
|
||||
actorType: "SYSTEM",
|
||||
action: "CLAIM_TIMEOUT_REAPER",
|
||||
entityType: "TASK",
|
||||
entityId: claim.task_id,
|
||||
beforeState: { status: TaskStatus.EXECUTING },
|
||||
afterState: { status: TaskStatus.OPEN },
|
||||
reason: `Claim ${claim.id} expired at ${claim.expires_at.toISOString()}`,
|
||||
});
|
||||
});
|
||||
rolledBackIds.push(claim.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
swept: rolledBackIds.length,
|
||||
task_ids: rolledBackIds,
|
||||
});
|
||||
}
|
||||
245
apps/web/src/app/api/mcp/[tool]/route.ts
Normal file
245
apps/web/src/app/api/mcp/[tool]/route.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
ListOpenTasksRequestSchema,
|
||||
ClaimTaskRequestSchema,
|
||||
SubmitSolutionRequestSchema,
|
||||
TaskStatus,
|
||||
JudgeOverallResult
|
||||
} from "@agent-bounty/contracts";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { runSubmissionInSandbox } from "@/lib/sandbox";
|
||||
import { logAuditEvent } from "@/lib/audit";
|
||||
import { redis } from "@/lib/redis";
|
||||
import crypto from "crypto";
|
||||
|
||||
export async function POST(request: NextRequest, props: { params: Promise<{ tool: string }> }) {
|
||||
const params = await props.params;
|
||||
const tool = params.tool;
|
||||
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return NextResponse.json({ error: "Unauthorized: Missing Bearer token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
if (process.env.API_KEY && token !== process.env.API_KEY) {
|
||||
return NextResponse.json({ error: "Forbidden: Invalid API Key" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
switch (tool) {
|
||||
case "list_open_tasks": {
|
||||
ListOpenTasksRequestSchema.parse(body);
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { status: TaskStatus.OPEN },
|
||||
});
|
||||
|
||||
const formattedTasks = tasks.map((t) => ({
|
||||
task_id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
difficulty: t.difficulty,
|
||||
reward: {
|
||||
amount: t.reward_amount,
|
||||
currency: t.reward_currency,
|
||||
display_amount: `$${(t.reward_amount / 100).toFixed(2)}`
|
||||
},
|
||||
required_stack: t.required_stack,
|
||||
scope_clarity_score: t.scope_clarity_score,
|
||||
created_at: t.created_at.toISOString(),
|
||||
description_preview: t.description.substring(0, 100) + (t.description.length > 100 ? "..." : ""),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
tasks: formattedTasks,
|
||||
total_open: formattedTasks.length,
|
||||
stockout_warning: formattedTasks.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
case "claim_task": {
|
||||
const parsed = ClaimTaskRequestSchema.parse(body);
|
||||
|
||||
const claim = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.task.updateMany({
|
||||
where: { id: parsed.task_id, status: TaskStatus.OPEN },
|
||||
data: { status: TaskStatus.EXECUTING }
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
throw new Error("Task is not OPEN or does not exist");
|
||||
}
|
||||
|
||||
const task = await tx.task.findUniqueOrThrow({ where: { id: parsed.task_id } });
|
||||
|
||||
const newClaim = await tx.claim.create({
|
||||
data: {
|
||||
task_id: task.id,
|
||||
developer_wallet: parsed.developer_wallet,
|
||||
status: TaskStatus.EXECUTING,
|
||||
claim_token: crypto.randomUUID(),
|
||||
held_amount: task.reward_amount,
|
||||
held_currency: task.reward_currency,
|
||||
expires_at: new Date(Date.now() + 3600000)
|
||||
}
|
||||
});
|
||||
|
||||
await logAuditEvent(tx, {
|
||||
actorType: "AGENT",
|
||||
actorId: parsed.developer_wallet,
|
||||
action: "CLAIM_TASK",
|
||||
entityType: "TASK",
|
||||
entityId: task.id,
|
||||
beforeState: { status: TaskStatus.OPEN },
|
||||
afterState: { status: TaskStatus.EXECUTING }
|
||||
});
|
||||
|
||||
return newClaim;
|
||||
});
|
||||
|
||||
// Set Redis TTL key (3600 seconds)
|
||||
await redis.set(`vw:task:${claim.task_id}:executing`, claim.claim_token, "EX", 3600);
|
||||
|
||||
return NextResponse.json({
|
||||
task_id: claim.task_id,
|
||||
status: claim.status,
|
||||
held_amount: claim.held_amount,
|
||||
held_currency: claim.held_currency,
|
||||
expires_at: claim.expires_at.toISOString(),
|
||||
claim_token: claim.claim_token,
|
||||
});
|
||||
}
|
||||
|
||||
case "submit_solution": {
|
||||
const parsed = SubmitSolutionRequestSchema.parse(body);
|
||||
|
||||
const submission = await prisma.$transaction(async (tx) => {
|
||||
const claim = await tx.claim.findUnique({ where: { claim_token: parsed.claim_token } });
|
||||
if (!claim || claim.task_id !== parsed.task_id || claim.status !== TaskStatus.EXECUTING) {
|
||||
throw new Error("Invalid claim token or claim is not EXECUTING");
|
||||
}
|
||||
|
||||
const updatedTask = await tx.task.updateMany({
|
||||
where: { id: parsed.task_id, status: TaskStatus.EXECUTING },
|
||||
data: { status: TaskStatus.VERIFYING }
|
||||
});
|
||||
|
||||
if (updatedTask.count === 0) {
|
||||
throw new Error("Task is not EXECUTING");
|
||||
}
|
||||
|
||||
await tx.claim.update({
|
||||
where: { id: claim.id },
|
||||
data: { status: TaskStatus.VERIFYING }
|
||||
});
|
||||
|
||||
const newSubmission = await tx.submission.create({
|
||||
data: {
|
||||
task_id: parsed.task_id,
|
||||
claim_id: claim.id,
|
||||
status: TaskStatus.VERIFYING,
|
||||
deliverables: parsed.deliverables as any,
|
||||
estimated_judge_complete_at: new Date(Date.now() + 300000)
|
||||
}
|
||||
});
|
||||
|
||||
await logAuditEvent(tx, {
|
||||
actorType: "AGENT",
|
||||
action: "SUBMIT_SOLUTION",
|
||||
entityType: "TASK",
|
||||
entityId: parsed.task_id,
|
||||
beforeState: { status: TaskStatus.EXECUTING },
|
||||
afterState: { status: TaskStatus.VERIFYING }
|
||||
});
|
||||
|
||||
return newSubmission;
|
||||
});
|
||||
|
||||
// Async trigger E2B Sandbox evaluation
|
||||
const taskObj = await prisma.task.findUnique({ where: { id: submission.task_id }});
|
||||
if (taskObj && typeof taskObj.acceptance_criteria === "object" && taskObj.acceptance_criteria !== null) {
|
||||
const criteria = taskObj.acceptance_criteria as any;
|
||||
if (criteria.test_file_content) {
|
||||
// Fire and forget
|
||||
runSubmissionInSandbox(
|
||||
submission.id,
|
||||
parsed.deliverables as Record<string, string>,
|
||||
criteria.test_file_content
|
||||
).then(async (result) => {
|
||||
// Update submission
|
||||
await prisma.submission.update({
|
||||
where: { id: submission.id },
|
||||
data: { status: "JUDGED" }
|
||||
});
|
||||
|
||||
// Create JudgeResult
|
||||
await prisma.judgeResult.create({
|
||||
data: {
|
||||
submission_id: submission.id,
|
||||
overall_result: result.overall_result,
|
||||
tests: result.tests,
|
||||
artifacts: result.artifacts,
|
||||
error_classification: result.error_classification,
|
||||
resource_usage: result.resource_usage
|
||||
}
|
||||
});
|
||||
|
||||
// Update Task & Claim Status
|
||||
const newTaskStatus = result.overall_result === JudgeOverallResult.PASS
|
||||
? TaskStatus.COMPLETED
|
||||
: TaskStatus.FAILED_RETRYABLE;
|
||||
|
||||
await prisma.task.update({
|
||||
where: { id: submission.task_id },
|
||||
data: { status: newTaskStatus }
|
||||
});
|
||||
|
||||
await prisma.claim.update({
|
||||
where: { id: submission.claim_id },
|
||||
data: { status: newTaskStatus }
|
||||
});
|
||||
|
||||
// @ts-ignore prisma transaction client vs prisma client
|
||||
await logAuditEvent(prisma, {
|
||||
actorType: "SYSTEM",
|
||||
action: "JUDGE_COMPLETE",
|
||||
entityType: "TASK",
|
||||
entityId: submission.task_id,
|
||||
beforeState: { status: TaskStatus.VERIFYING },
|
||||
afterState: { status: newTaskStatus },
|
||||
metadata: { overall_result: result.overall_result, error_classification: result.error_classification }
|
||||
});
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
task_id: submission.task_id,
|
||||
submission_id: submission.id,
|
||||
status: submission.status,
|
||||
estimated_judge_complete_at: submission.estimated_judge_complete_at?.toISOString() ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
case "check_payout_status": {
|
||||
// Mocked for now until Settlement phase is implemented
|
||||
return NextResponse.json({
|
||||
task_id: body.task_id,
|
||||
phase: "PAYOUT_READY",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown tool: ${tool}` }, { status: 404 });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[API Gateway] Error handling ${tool}:`, error);
|
||||
return NextResponse.json({ error: error.message || String(error) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
apps/web/src/app/globals.css
Normal file
26
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
33
apps/web/src/app/layout.tsx
Normal file
33
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/app/page.tsx
Normal file
64
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function Home() {
|
||||
const tasks = await prisma.task.findMany({
|
||||
orderBy: { created_at: "desc" }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-4xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500">
|
||||
Agent Bounty Protocol
|
||||
</h1>
|
||||
<Link href="/tasks/create" className="bg-blue-600 hover:bg-blue-500 text-white font-medium py-2 px-6 rounded-full transition-all duration-300 shadow-lg shadow-blue-500/30">
|
||||
+ Post Bounty
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="col-span-full text-center py-20 text-gray-500">
|
||||
No tasks available. Be the first to post a bounty!
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<Link href={`/tasks/${task.id}`} key={task.id} className="block group">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6 h-full transition-all duration-300 hover:border-blue-500 hover:shadow-[0_0_20px_rgba(59,130,246,0.15)] relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-purple-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded-full ${
|
||||
task.status === "OPEN" ? "bg-green-500/10 text-green-400 border border-green-500/20" :
|
||||
task.status === "EXECUTING" ? "bg-yellow-500/10 text-yellow-400 border border-yellow-500/20" :
|
||||
task.status === "COMPLETED" ? "bg-blue-500/10 text-blue-400 border border-blue-500/20" :
|
||||
"bg-gray-800 text-gray-400 border border-gray-700"
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-gray-200">
|
||||
${(task.reward_amount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-white mb-2 line-clamp-1">{task.title}</h2>
|
||||
<p className="text-gray-400 text-sm mb-6 line-clamp-2">{task.description}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-auto">
|
||||
{task.required_stack.map((tech) => (
|
||||
<span key={tech} className="text-xs bg-gray-800 text-gray-300 px-2 py-1 rounded">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/app/tasks/[id]/page.tsx
Normal file
71
apps/web/src/app/tasks/[id]/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function TaskDetails({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id },
|
||||
include: { submissions: true }
|
||||
});
|
||||
|
||||
if (!task) return notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Link href="/" className="text-blue-400 hover:text-blue-300 mb-8 inline-flex items-center gap-2">
|
||||
← Back to Tasks
|
||||
</Link>
|
||||
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-3xl p-10 relative overflow-hidden shadow-2xl">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"></div>
|
||||
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<h1 className="text-4xl font-extrabold text-white">{task.title}</h1>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-emerald-600">
|
||||
${(task.reward_amount / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{task.reward_currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-8">
|
||||
<span className="px-4 py-2 bg-gray-800 rounded-lg text-sm font-semibold border border-gray-700">
|
||||
Status: <span className="text-blue-400">{task.status}</span>
|
||||
</span>
|
||||
<span className="px-4 py-2 bg-gray-800 rounded-lg text-sm font-semibold border border-gray-700">
|
||||
Difficulty: <span className="text-purple-400">{task.difficulty}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-invert max-w-none mb-10">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-200 border-b border-gray-800 pb-2">Description</h3>
|
||||
<p className="text-gray-400 leading-relaxed whitespace-pre-wrap">{task.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-200 border-b border-gray-800 pb-2">Required Stack</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.required_stack.map((tech) => (
|
||||
<span key={tech} className="bg-blue-900/30 text-blue-300 px-3 py-1.5 rounded-md text-sm font-medium border border-blue-800/50">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-950 rounded-xl p-6 border border-gray-800">
|
||||
<h3 className="text-lg font-bold mb-4 text-gray-300">Acceptance Criteria (Test File)</h3>
|
||||
<pre className="text-xs text-gray-400 overflow-x-auto p-4 bg-black rounded-lg border border-gray-800">
|
||||
{typeof task.acceptance_criteria === "object" && task.acceptance_criteria !== null
|
||||
? (task.acceptance_criteria as any).test_file_content || "No test file specified."
|
||||
: "N/A"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/app/tasks/create/actions.ts
Normal file
32
apps/web/src/app/tasks/create/actions.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { TaskStatus, TaskDifficulty } from "@agent-bounty/contracts";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function createTask(formData: FormData) {
|
||||
const title = formData.get("title") as string;
|
||||
const description = formData.get("description") as string;
|
||||
const rewardAmount = parseInt(formData.get("rewardAmount") as string, 10) * 100; // to cents
|
||||
const requiredStack = (formData.get("requiredStack") as string).split(",").map(s => s.trim());
|
||||
const testFileContent = formData.get("testFileContent") as string;
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
status: TaskStatus.OPEN,
|
||||
difficulty: TaskDifficulty.COMPONENT,
|
||||
scope_clarity_score: 1.0,
|
||||
reward_amount: rewardAmount,
|
||||
reward_currency: "USD",
|
||||
required_stack: requiredStack,
|
||||
acceptance_criteria: {
|
||||
validation_mode: "VITEST_UNIT",
|
||||
test_file_content: testFileContent
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
redirect(`/tasks/${task.id}`);
|
||||
}
|
||||
96
apps/web/src/app/tasks/create/page.tsx
Normal file
96
apps/web/src/app/tasks/create/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { createTask } from "./actions";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function CreateTaskPage() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
startTransition(() => {
|
||||
createTask(formData);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Link href="/" className="text-blue-400 hover:text-blue-300 mb-8 inline-flex items-center gap-2">
|
||||
← Back to Tasks
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-extrabold text-white mb-8">Post a New Bounty</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-3xl p-8 shadow-2xl space-y-6">
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-300 mb-2">Title</label>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="w-full bg-black border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="e.g. Build a React Button Component"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-300 mb-2">Reward Amount (USD)</label>
|
||||
<input
|
||||
name="rewardAmount"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
className="w-full bg-black border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-300 mb-2">Required Stack (comma separated)</label>
|
||||
<input
|
||||
name="requiredStack"
|
||||
required
|
||||
className="w-full bg-black border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="React, Tailwind, TypeScript"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-300 mb-2">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows={4}
|
||||
className="w-full bg-black border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="Describe the task details here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-300 mb-2">Acceptance Criteria (Vitest Code)</label>
|
||||
<textarea
|
||||
name="testFileContent"
|
||||
required
|
||||
rows={6}
|
||||
className="w-full bg-black border border-gray-700 rounded-lg px-4 py-3 text-white font-mono text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="import { expect, test } from 'vitest'; test('component renders', () => { ... });"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">Provide the exact test file content that the agent's solution must pass.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-bold py-4 rounded-xl transition-all duration-300 shadow-lg shadow-blue-500/30 disabled:opacity-50 disabled:cursor-not-allowed mt-4"
|
||||
>
|
||||
{isPending ? "Posting Bounty..." : "Post Bounty to Network"}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/lib/audit.ts
Normal file
31
apps/web/src/lib/audit.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export async function logAuditEvent(
|
||||
tx: Prisma.TransactionClient,
|
||||
params: {
|
||||
actorType: "SYSTEM" | "AGENT" | "USER";
|
||||
actorId?: string;
|
||||
action: string;
|
||||
entityType: "TASK" | "CLAIM" | "SUBMISSION" | "JUDGE_RESULT";
|
||||
entityId: string;
|
||||
beforeState?: any;
|
||||
afterState?: any;
|
||||
reason?: string;
|
||||
metadata?: any;
|
||||
}
|
||||
) {
|
||||
return tx.auditEvent.create({
|
||||
data: {
|
||||
actorType: params.actorType,
|
||||
actorId: params.actorId,
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
beforeState: params.beforeState ?? Prisma.JsonNull,
|
||||
afterState: params.afterState ?? Prisma.JsonNull,
|
||||
reason: params.reason,
|
||||
metadata: params.metadata ?? Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
}
|
||||
7
apps/web/src/lib/prisma.ts
Normal file
7
apps/web/src/lib/prisma.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma || new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
9
apps/web/src/lib/redis.ts
Normal file
9
apps/web/src/lib/redis.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
const globalForRedis = global as unknown as { redis: Redis };
|
||||
|
||||
export const redis =
|
||||
globalForRedis.redis ||
|
||||
new Redis(process.env.REDIS_URL || "redis://localhost:6379");
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForRedis.redis = redis;
|
||||
76
apps/web/src/lib/sandbox.ts
Normal file
76
apps/web/src/lib/sandbox.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Sandbox } from "@e2b/code-interpreter";
|
||||
import { JudgeOverallResult, JudgeErrorClassification } from "@agent-bounty/contracts";
|
||||
|
||||
export async function runSubmissionInSandbox(
|
||||
submissionId: string,
|
||||
deliverables: Record<string, string>,
|
||||
testFileContent: string
|
||||
) {
|
||||
let sandbox;
|
||||
try {
|
||||
sandbox = await Sandbox.create();
|
||||
|
||||
// Setup: Initialize a simple project
|
||||
await sandbox.commands.run("npm init -y");
|
||||
await sandbox.commands.run("npm install vitest react react-dom @types/react @types/react-dom");
|
||||
|
||||
// Write deliverables
|
||||
for (const [filepath, content] of Object.entries(deliverables)) {
|
||||
// In a real implementation we would ensure directories exist
|
||||
// For MVP we assume flat files or e2b handles basic paths
|
||||
await sandbox.files.write(filepath, content);
|
||||
}
|
||||
|
||||
// Write test file
|
||||
await sandbox.files.write("test.spec.tsx", testFileContent);
|
||||
|
||||
// Run tests
|
||||
const result = await sandbox.commands.run("npx vitest run test.spec.tsx --reporter json", {
|
||||
timeout: 120000 // 2 minutes
|
||||
});
|
||||
|
||||
let overall = JudgeOverallResult.FAIL;
|
||||
let errorClass = result.exitCode === 0 ? null : JudgeErrorClassification.TEST_FAIL;
|
||||
let parsedTests = [];
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
overall = JudgeOverallResult.PASS;
|
||||
}
|
||||
|
||||
try {
|
||||
if (result.stdout) {
|
||||
const jsonResult = JSON.parse(result.stdout);
|
||||
parsedTests = jsonResult.testResults || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse vitest JSON output", e);
|
||||
}
|
||||
|
||||
return {
|
||||
overall_result: overall,
|
||||
error_classification: errorClass,
|
||||
tests: parsedTests,
|
||||
artifacts: {
|
||||
logs: result.stdout + "\n" + result.stderr
|
||||
},
|
||||
resource_usage: {
|
||||
cpu_ms: 0,
|
||||
mem_peak_mb: 0,
|
||||
io_bytes: 0
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Sandbox evaluation failed", error);
|
||||
return {
|
||||
overall_result: JudgeOverallResult.FAIL,
|
||||
error_classification: JudgeErrorClassification.ENVIRONMENT_ERROR,
|
||||
tests: [],
|
||||
artifacts: { logs: error.message },
|
||||
resource_usage: { cpu_ms: 0, mem_peak_mb: 0, io_bytes: 0 }
|
||||
};
|
||||
} finally {
|
||||
if (sandbox) {
|
||||
await sandbox.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/web/tsconfig.json
Normal file
34
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
44
docker-compose.yml
Normal file
44
docker-compose.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
container_name: agent_bounty_db
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: agent
|
||||
POSTGRES_PASSWORD: agent_password_secure
|
||||
POSTGRES_DB: agent_bounty
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- agent_bounty_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U agent"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: agent_bounty_web
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Use the docker internal network to connect to postgres
|
||||
- DATABASE_URL=postgresql://agent:agent_password_secure@db:5432/agent_bounty?schema=public
|
||||
- NODE_ENV=production
|
||||
- API_KEY=${API_KEY:-"super-secret-mcp-key"}
|
||||
- E2B_API_KEY=${E2B_API_KEY:-""}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# We use a command override to run database push before starting next.js
|
||||
command: >
|
||||
sh -c "npx prisma db push --schema=apps/web/prisma/schema.prisma && node apps/web/server.js"
|
||||
|
||||
volumes:
|
||||
agent_bounty_pgdata:
|
||||
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "agent-bounty-protocol",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
44
packages/contracts/package.json
Normal file
44
packages/contracts/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@agent-bounty/contracts",
|
||||
"version": "0.1.0",
|
||||
"description": "VibeWork shared data contracts: Task/Lead/Settlement/Judge schemas, enums, and TypeScript types",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./enums": {
|
||||
"import": "./dist/enums/index.js",
|
||||
"types": "./dist/enums/index.d.ts"
|
||||
},
|
||||
"./schemas": {
|
||||
"import": "./dist/schemas/index.js",
|
||||
"types": "./dist/schemas/index.d.ts"
|
||||
},
|
||||
"./types": {
|
||||
"import": "./dist/types/index.js",
|
||||
"types": "./dist/types/index.d.ts"
|
||||
},
|
||||
"./errors": {
|
||||
"import": "./dist/errors/index.js",
|
||||
"types": "./dist/errors/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"dev": "tsc --project tsconfig.json --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
230
packages/contracts/src/enums/index.ts
Normal file
230
packages/contracts/src/enums/index.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* @agent-bounty/contracts — Enums
|
||||
*
|
||||
* 所有狀態機 enum、分類常數的唯一真實來源。
|
||||
* MCP server、後端 API、前端 UI 都必須 import 這裡,禁止各自重新定義。
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Task 任務生命週期狀態機
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* TaskStatus:任務在系統中的完整生命週期。
|
||||
*
|
||||
* 狀態轉換規則(前後端、MCP、Judge Agent 必遵守):
|
||||
*
|
||||
* OPEN
|
||||
* → EXECUTING (claim_task:DB SELECT FOR UPDATE + Stripe Auth-Hold + Redis setex TTL 3600s)
|
||||
* → CANCELLED (client_cancel)
|
||||
*
|
||||
* EXECUTING
|
||||
* → VERIFYING (submit_solution)
|
||||
* → OPEN (Redis TTL expired → rollback + 釋放 Auth-Hold)
|
||||
* → CANCELLED (agent_cancel / client_cancel)
|
||||
* → FAILED (agent_abandon)
|
||||
*
|
||||
* VERIFYING
|
||||
* → COMPLETED (judge exit_code=0 → Stripe Capture → Ledger)
|
||||
* → FAILED_RETRYABLE (judge fail retryable, or timeout ≤ 5min)
|
||||
* → FAILED (judge fail non-retryable)
|
||||
*
|
||||
* FAILED_RETRYABLE
|
||||
* → OPEN (retry_count ≤ 3, auto)
|
||||
* → FAILED (retry_count > 3)
|
||||
*
|
||||
* FAILED
|
||||
* → OPEN (policy=auto_open)
|
||||
* → ARCHIVED (policy=archive)
|
||||
* → DISPUTED (client_dispute)
|
||||
*
|
||||
* COMPLETED
|
||||
* → DISPUTED (client_dispute, within 72h)
|
||||
* → PAYOUT_READY (dispute window closed, no dispute)
|
||||
* → PAYOUT_SETTLED (payout processed)
|
||||
*
|
||||
* DISPUTED
|
||||
* → OPEN (dispute_resolved: reopen)
|
||||
* → COMPLETED (dispute_resolved: keep)
|
||||
* → REFUND_PENDING (dispute_resolved: refund)
|
||||
* → ARCHIVED (dispute_resolved: archive)
|
||||
*
|
||||
* REFUND_PENDING → ARCHIVED
|
||||
* CANCELLED → ARCHIVED
|
||||
* PAYOUT_SETTLED → ARCHIVED
|
||||
* ARCHIVED → (終態)
|
||||
*/
|
||||
export const TaskStatus = {
|
||||
OPEN: "OPEN",
|
||||
EXECUTING: "EXECUTING",
|
||||
VERIFYING: "VERIFYING",
|
||||
COMPLETED: "COMPLETED",
|
||||
FAILED: "FAILED",
|
||||
FAILED_RETRYABLE: "FAILED_RETRYABLE",
|
||||
CANCELLED: "CANCELLED",
|
||||
DISPUTED: "DISPUTED",
|
||||
REFUND_PENDING: "REFUND_PENDING",
|
||||
PAYOUT_READY: "PAYOUT_READY",
|
||||
PAYOUT_SETTLED: "PAYOUT_SETTLED",
|
||||
ARCHIVED: "ARCHIVED",
|
||||
} as const;
|
||||
export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus];
|
||||
|
||||
/** 終態集合:進入後不可再轉換(除人工介入) */
|
||||
export const TERMINAL_TASK_STATUSES: ReadonlySet<TaskStatus> = new Set([
|
||||
TaskStatus.ARCHIVED,
|
||||
]);
|
||||
|
||||
/** 可從 FAILED/FAILED_RETRYABLE 自動重開的最大重試次數 */
|
||||
export const MAX_TASK_RETRY_COUNT = 3;
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Task 難度分級
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* TaskDifficulty:對應種子任務的定價與驗收複雜度。
|
||||
* HELLO_WORLD = $1 / COMPONENT = $5 / VIEW = $10 / EPIC = $20+
|
||||
*/
|
||||
export const TaskDifficulty = {
|
||||
HELLO_WORLD: "HELLO_WORLD",
|
||||
COMPONENT: "COMPONENT",
|
||||
VIEW: "VIEW",
|
||||
EPIC: "EPIC",
|
||||
} as const;
|
||||
export type TaskDifficulty = (typeof TaskDifficulty)[keyof typeof TaskDifficulty];
|
||||
|
||||
/** 難度對應的預設獎金(USD cents) */
|
||||
export const TASK_DIFFICULTY_DEFAULT_REWARD_CENTS: Record<TaskDifficulty, number> = {
|
||||
HELLO_WORLD: 100,
|
||||
COMPONENT: 500,
|
||||
VIEW: 1000,
|
||||
EPIC: 2000,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Task 驗收模式
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const ValidationMode = {
|
||||
VITEST_UNIT: "VITEST_UNIT",
|
||||
PLAYWRIGHT_E2E: "PLAYWRIGHT_E2E",
|
||||
AST_PARSING: "AST_PARSING", // 僅用於靜態 HTML/CSS,不得用於 React 元件
|
||||
VISUAL_REGRESSION: "VISUAL_REGRESSION",
|
||||
} as const;
|
||||
export type ValidationMode = (typeof ValidationMode)[keyof typeof ValidationMode];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Judge 執行結果
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const JudgeOverallResult = {
|
||||
PASS: "pass",
|
||||
FAIL: "fail",
|
||||
TIMEOUT: "timeout",
|
||||
} as const;
|
||||
export type JudgeOverallResult = (typeof JudgeOverallResult)[keyof typeof JudgeOverallResult];
|
||||
|
||||
export const JudgeTestStatus = {
|
||||
PASSED: "passed",
|
||||
FAILED: "failed",
|
||||
SKIPPED: "skipped",
|
||||
} as const;
|
||||
export type JudgeTestStatus = (typeof JudgeTestStatus)[keyof typeof JudgeTestStatus];
|
||||
|
||||
/**
|
||||
* JudgeErrorClassification:標準化失敗分類。
|
||||
* 影響後端決策:是否重試 / 扣押 Agent 分數 / 進入人工仲裁。
|
||||
*/
|
||||
export const JudgeErrorClassification = {
|
||||
TEST_FAIL: "TEST_FAIL", // 邏輯/行為不符(可 retryable)
|
||||
LINT_FAIL: "LINT_FAIL", // lint 錯誤(可 retryable)
|
||||
TIMEOUT: "TIMEOUT", // 超過 5 分鐘(可 retryable)
|
||||
RESOURCE_EXHAUSTED: "RESOURCE_EXHAUSTED", // CPU/Memory 超限(可 retryable)
|
||||
ENVIRONMENT_ERROR: "ENVIRONMENT_ERROR", // 沙盒或工具層失敗(不計 Agent 分)
|
||||
NETWORK_DENIED: "NETWORK_DENIED", // 試圖違規網路存取(non-retryable)
|
||||
SANDBOX_CRASH: "SANDBOX_CRASH", // E2B 內部崩潰(不計 Agent 分)
|
||||
TEST_SETUP_FAIL: "TEST_SETUP_FAIL", // 映像檔缺少依賴(不計 Agent 分)
|
||||
ENV_MISCONFIG: "ENV_MISCONFIG", // 設定錯誤(不計 Agent 分)
|
||||
} as const;
|
||||
export type JudgeErrorClassification = (typeof JudgeErrorClassification)[keyof typeof JudgeErrorClassification];
|
||||
|
||||
/** 不計入 Agent 失敗分數的環境層錯誤 */
|
||||
export const JUDGE_ENVIRONMENT_ERROR_TYPES: ReadonlySet<JudgeErrorClassification> = new Set([
|
||||
JudgeErrorClassification.ENVIRONMENT_ERROR,
|
||||
JudgeErrorClassification.SANDBOX_CRASH,
|
||||
JudgeErrorClassification.TEST_SETUP_FAIL,
|
||||
JudgeErrorClassification.ENV_MISCONFIG,
|
||||
]);
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 金流 / Settlement
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const SettlementPhase = {
|
||||
AUTH_HOLD: "auth_hold",
|
||||
CAPTURE: "capture",
|
||||
RELEASE: "release",
|
||||
REFUND: "refund",
|
||||
PAYOUT: "payout",
|
||||
DISPUTE: "dispute",
|
||||
CORRECTION: "correction",
|
||||
} as const;
|
||||
export type SettlementPhase = (typeof SettlementPhase)[keyof typeof SettlementPhase];
|
||||
|
||||
export const SettlementCaptureMode = {
|
||||
STRIPE_AUTH_CAPTURE: "STRIPE_AUTH_CAPTURE",
|
||||
BASE_SMART_CONTRACT: "BASE_SMART_CONTRACT",
|
||||
} as const;
|
||||
export type SettlementCaptureMode = (typeof SettlementCaptureMode)[keyof typeof SettlementCaptureMode];
|
||||
|
||||
export const SupportedCurrency = {
|
||||
USD: "USD",
|
||||
TWD: "TWD",
|
||||
USDC: "USDC",
|
||||
} as const;
|
||||
export type SupportedCurrency = (typeof SupportedCurrency)[keyof typeof SupportedCurrency];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Lead / Scout
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const LeadStatus = {
|
||||
DRAFT: "DRAFT",
|
||||
CONFIRMED: "CONFIRMED",
|
||||
PAYMENT_AUTHORIZED: "PAYMENT_AUTHORIZED",
|
||||
TASK_CREATED: "TASK_CREATED",
|
||||
EXPIRED: "EXPIRED",
|
||||
CANCELLED: "CANCELLED",
|
||||
} as const;
|
||||
export type LeadStatus = (typeof LeadStatus)[keyof typeof LeadStatus];
|
||||
|
||||
/** Scout 引流歸因模式。Phase 1 強制使用 LAST_CLICK。 */
|
||||
export const AttributionModel = {
|
||||
LAST_CLICK: "LAST_CLICK",
|
||||
FIRST_CLICK: "FIRST_CLICK",
|
||||
LINEAR: "LINEAR",
|
||||
} as const;
|
||||
export type AttributionModel = (typeof AttributionModel)[keyof typeof AttributionModel];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Error 分類
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const TaskErrorClassification = {
|
||||
RETRYABLE: "retryable",
|
||||
NON_RETRYABLE: "non_retryable",
|
||||
} as const;
|
||||
export type TaskErrorClassification = (typeof TaskErrorClassification)[keyof typeof TaskErrorClassification];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// MCP Tool 名稱(唯一常數來源)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const MCPToolName = {
|
||||
LIST_OPEN_TASKS: "list_open_tasks",
|
||||
CLAIM_TASK: "claim_task",
|
||||
SUBMIT_SOLUTION: "submit_solution",
|
||||
CHECK_PAYOUT_STATUS: "check_payout_status",
|
||||
} as const;
|
||||
export type MCPToolName = (typeof MCPToolName)[keyof typeof MCPToolName];
|
||||
101
packages/contracts/src/errors/index.ts
Normal file
101
packages/contracts/src/errors/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @agent-bounty/contracts — Error Codes
|
||||
*
|
||||
* 所有 API / MCP 層的標準錯誤碼。
|
||||
* 格式:VW_<DOMAIN>_<CODE>
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Task 操作錯誤
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const TaskErrorCode = {
|
||||
// claim_task
|
||||
TASK_NOT_FOUND: "VW_TASK_NOT_FOUND",
|
||||
TASK_NOT_OPEN: "VW_TASK_NOT_OPEN", // 狀態不是 OPEN,無法 claim
|
||||
TASK_ALREADY_CLAIMED: "VW_TASK_ALREADY_CLAIMED", // 409 Race Condition(SELECT FOR UPDATE 失敗)
|
||||
TASK_CLAIM_LIMIT_REACHED: "VW_TASK_CLAIM_LIMIT_REACHED", // Agent 已達同時接案上限
|
||||
TASK_AGENT_SUSPENDED: "VW_TASK_AGENT_SUSPENDED", // Agent 被降權或封鎖
|
||||
|
||||
// submit_solution
|
||||
TASK_NOT_EXECUTING: "VW_TASK_NOT_EXECUTING", // 無法提交,任務不在 EXECUTING
|
||||
TASK_SUBMIT_UNAUTHORIZED: "VW_TASK_SUBMIT_UNAUTHORIZED", // 403,非任務持有者提交
|
||||
TASK_PAYLOAD_TOO_LARGE: "VW_TASK_PAYLOAD_TOO_LARGE", // 413,超過 payload 上限
|
||||
TASK_FILE_COUNT_EXCEEDED: "VW_TASK_FILE_COUNT_EXCEEDED", // 超過最大檔案數(20)
|
||||
|
||||
// 通用
|
||||
TASK_INVALID_INPUT: "VW_TASK_INVALID_INPUT",
|
||||
TASK_FORBIDDEN: "VW_TASK_FORBIDDEN",
|
||||
} as const;
|
||||
export type TaskErrorCode = (typeof TaskErrorCode)[keyof typeof TaskErrorCode];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Judge 執行錯誤
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const JudgeErrorCode = {
|
||||
JUDGE_TIMEOUT: "VW_JUDGE_TIMEOUT",
|
||||
JUDGE_SANDBOX_UNAVAILABLE: "VW_JUDGE_SANDBOX_UNAVAILABLE",
|
||||
JUDGE_IMAGE_NOT_FOUND: "VW_JUDGE_IMAGE_NOT_FOUND",
|
||||
JUDGE_INVALID_RESULT: "VW_JUDGE_INVALID_RESULT",
|
||||
JUDGE_ALREADY_RUNNING: "VW_JUDGE_ALREADY_RUNNING", // 冪等保護:同一任務重複觸發
|
||||
} as const;
|
||||
export type JudgeErrorCode = (typeof JudgeErrorCode)[keyof typeof JudgeErrorCode];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 金流錯誤
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const SettlementErrorCode = {
|
||||
PAYMENT_INTENT_NOT_FOUND: "VW_PAYMENT_INTENT_NOT_FOUND",
|
||||
AUTH_HOLD_FAILED: "VW_AUTH_HOLD_FAILED",
|
||||
CAPTURE_FAILED: "VW_CAPTURE_FAILED",
|
||||
RELEASE_FAILED: "VW_RELEASE_FAILED",
|
||||
REFUND_FAILED: "VW_REFUND_FAILED",
|
||||
IDEMPOTENCY_CONFLICT: "VW_IDEMPOTENCY_CONFLICT", // 同 key 但不同金額
|
||||
LEDGER_DRIFT_DETECTED: "VW_LEDGER_DRIFT_DETECTED", // 對帳漂移,啟動 incident
|
||||
DUPLICATE_WEBHOOK: "VW_DUPLICATE_WEBHOOK", // webhook 重複接收
|
||||
} as const;
|
||||
export type SettlementErrorCode = (typeof SettlementErrorCode)[keyof typeof SettlementErrorCode];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Lead / Scout 錯誤
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const LeadErrorCode = {
|
||||
LEAD_NOT_FOUND: "VW_LEAD_NOT_FOUND",
|
||||
LEAD_EXPIRED: "VW_LEAD_EXPIRED",
|
||||
LEAD_INVALID_TEMPLATE: "VW_LEAD_INVALID_TEMPLATE", // Scout 使用未授權模板
|
||||
SCOUT_RATE_LIMIT: "VW_SCOUT_RATE_LIMIT",
|
||||
SCOUT_SUSPENDED: "VW_SCOUT_SUSPENDED",
|
||||
ATTRIBUTION_TOKEN_INVALID: "VW_ATTRIBUTION_TOKEN_INVALID",
|
||||
} as const;
|
||||
export type LeadErrorCode = (typeof LeadErrorCode)[keyof typeof LeadErrorCode];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 通用 API 錯誤
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const CommonErrorCode = {
|
||||
UNAUTHORIZED: "VW_UNAUTHORIZED",
|
||||
FORBIDDEN: "VW_FORBIDDEN",
|
||||
VALIDATION_ERROR: "VW_VALIDATION_ERROR",
|
||||
RATE_LIMITED: "VW_RATE_LIMITED",
|
||||
INTERNAL_ERROR: "VW_INTERNAL_ERROR",
|
||||
SERVICE_UNAVAILABLE: "VW_SERVICE_UNAVAILABLE",
|
||||
} as const;
|
||||
export type CommonErrorCode = (typeof CommonErrorCode)[keyof typeof CommonErrorCode];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 統一 error response 結構(MCP + REST 共用)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export interface VWError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
/** 若為 true,呼叫方可在稍後重試 */
|
||||
retryable?: boolean;
|
||||
/** 建議的重試等待秒數 */
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
22
packages/contracts/src/index.ts
Normal file
22
packages/contracts/src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @agent-bounty/contracts
|
||||
*
|
||||
* VibeWork 共用資料契約套件
|
||||
* 版本:0.1.0
|
||||
*
|
||||
* 這是整個 VibeWork 系統的「單一資料契約真實來源」。
|
||||
* MCP server、後端 API、前端表單都必須從這裡 import,
|
||||
* 禁止在各自的程式庫中重新定義任何 VibeWork 資料結構。
|
||||
*/
|
||||
|
||||
// Enums — 狀態機、分類常數
|
||||
export * from "./enums/index.js";
|
||||
|
||||
// Schemas — Zod 驗證 schema
|
||||
export * from "./schemas/index.js";
|
||||
|
||||
// Types — TypeScript 型別(從 Zod 推導)
|
||||
export * from "./types/index.js";
|
||||
|
||||
// Errors — 標準錯誤碼與 VWError 介面
|
||||
export * from "./errors/index.js";
|
||||
377
packages/contracts/src/schemas/index.ts
Normal file
377
packages/contracts/src/schemas/index.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* @agent-bounty/contracts — Zod Validation Schemas
|
||||
*
|
||||
* 唯一的驗證真實來源。
|
||||
* MCP server、後端 API、前端表單都必須 import 這裡的 schema,
|
||||
* 禁止在各自的程式庫中重新定義任何 VibeWork 資料結構。
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
TaskStatus,
|
||||
TaskDifficulty,
|
||||
ValidationMode,
|
||||
JudgeOverallResult,
|
||||
JudgeTestStatus,
|
||||
JudgeErrorClassification,
|
||||
SettlementPhase,
|
||||
SettlementCaptureMode,
|
||||
SupportedCurrency,
|
||||
LeadStatus,
|
||||
AttributionModel,
|
||||
TaskErrorClassification,
|
||||
MAX_TASK_RETRY_COUNT,
|
||||
} from "../enums/index.js";
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 通用基礎型別
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const UUIDSchema = z.string().uuid("無效的 UUID 格式");
|
||||
const CUIDSchema = z.string().cuid("無效的 CUID 格式");
|
||||
const PositiveIntSchema = z.number().int().positive();
|
||||
const NonNegativeIntSchema = z.number().int().nonnegative();
|
||||
|
||||
/** 金額(以最小單位計,例如美分 / 台幣分) */
|
||||
const MoneyAmountSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative("金額不得為負數")
|
||||
.max(1_000_000_00, "單筆金額超過上限($1,000,000)");
|
||||
|
||||
/** scope_clarity_score:AI 評估任務清晰度,Phase 1 要求 ≥ 0.90 */
|
||||
const ScopeScoreSchema = z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.refine((v) => v >= 0.9, {
|
||||
message: "scope_clarity_score 必須 ≥ 0.90 才能進入主任務池",
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Task 驗收條件 Schema
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const AcceptanceCriteriaSchema = z.object({
|
||||
validation_mode: z.enum([
|
||||
ValidationMode.VITEST_UNIT,
|
||||
ValidationMode.PLAYWRIGHT_E2E,
|
||||
ValidationMode.AST_PARSING,
|
||||
ValidationMode.VISUAL_REGRESSION,
|
||||
]),
|
||||
/** 由平台提供的最小測試檔內容(string);Agent 不得修改此欄位 */
|
||||
test_file_content: z.string().min(20, "測試檔內容不得為空"),
|
||||
/** 關鍵斷言規則(可選,補充說明) */
|
||||
rules: z
|
||||
.array(
|
||||
z.object({
|
||||
assertion: z.string(),
|
||||
expected: z.unknown(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Task Bounty 核心 Schema(Open Bounty Board)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const TaskBountySchema = z.object({
|
||||
task_id: UUIDSchema,
|
||||
title: z.string().min(5).max(120),
|
||||
description: z.string().min(20).max(2000),
|
||||
status: z.enum([
|
||||
TaskStatus.OPEN,
|
||||
TaskStatus.EXECUTING,
|
||||
TaskStatus.VERIFYING,
|
||||
TaskStatus.COMPLETED,
|
||||
TaskStatus.FAILED,
|
||||
TaskStatus.FAILED_RETRYABLE,
|
||||
TaskStatus.CANCELLED,
|
||||
TaskStatus.DISPUTED,
|
||||
TaskStatus.REFUND_PENDING,
|
||||
TaskStatus.PAYOUT_READY,
|
||||
TaskStatus.PAYOUT_SETTLED,
|
||||
TaskStatus.ARCHIVED,
|
||||
]),
|
||||
difficulty: z.enum([
|
||||
TaskDifficulty.HELLO_WORLD,
|
||||
TaskDifficulty.COMPONENT,
|
||||
TaskDifficulty.VIEW,
|
||||
TaskDifficulty.EPIC,
|
||||
]),
|
||||
/** 最小可行要求 ≥ 0.90 才能進入公開任務池 */
|
||||
scope_clarity_score: ScopeScoreSchema,
|
||||
error_classification: z.enum([
|
||||
TaskErrorClassification.RETRYABLE,
|
||||
TaskErrorClassification.NON_RETRYABLE,
|
||||
]),
|
||||
reward: z.object({
|
||||
/** 金額(整數,以最小貨幣單位計:USD cents / TWD 元) */
|
||||
amount: MoneyAmountSchema,
|
||||
currency: z.enum([
|
||||
SupportedCurrency.USD,
|
||||
SupportedCurrency.TWD,
|
||||
SupportedCurrency.USDC,
|
||||
]),
|
||||
/** 顯示用格式化金額(例如 "NT$30" / "$1.00"),由後端產生 */
|
||||
display_amount: z.string().optional(),
|
||||
}),
|
||||
acceptance_criteria: AcceptanceCriteriaSchema,
|
||||
/** 要求使用的技術棧 */
|
||||
required_stack: z.array(z.string()).min(1).default(["React", "Tailwind CSS"]),
|
||||
/** 已重試次數 */
|
||||
retry_count: NonNegativeIntSchema.max(MAX_TASK_RETRY_COUNT).default(0),
|
||||
/** Auth-Hold 的 Stripe payment_intent_id */
|
||||
stripe_payment_intent_id: z.string().optional(),
|
||||
/** 任務到期時間(對應 Redis TTL) */
|
||||
expires_at: z.string().datetime().optional(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// claim_task Request/Response
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const ClaimTaskRequestSchema = z.object({
|
||||
task_id: UUIDSchema,
|
||||
/** Agent 收款錢包(Stripe Connect account 或 EVM 地址) */
|
||||
developer_wallet: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(0x[a-fA-F0-9]{40}|acct_[a-zA-Z0-9]+)$/,
|
||||
"developer_wallet 必須是有效的 EVM 地址或 Stripe Connect ID"
|
||||
),
|
||||
});
|
||||
|
||||
export const ClaimTaskResponseSchema = z.object({
|
||||
task_id: UUIDSchema,
|
||||
status: z.literal(TaskStatus.EXECUTING),
|
||||
/** Stripe Auth-Hold 的金額(供 Agent 確認) */
|
||||
held_amount: MoneyAmountSchema,
|
||||
held_currency: z.enum([SupportedCurrency.USD, SupportedCurrency.TWD]),
|
||||
/** Redis TTL 過期時間 */
|
||||
expires_at: z.string().datetime(),
|
||||
/** 接案 Agent 的冪等憑證 */
|
||||
claim_token: z.string().uuid(),
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// submit_solution Request/Response
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
const DeliverableFileSchema = z.object({
|
||||
path: z.string().min(1).max(260, "路徑長度超過限制"),
|
||||
content: z
|
||||
.string()
|
||||
.min(10, "檔案內容不得為空")
|
||||
.max(500_000, "單檔案內容超過 500KB 限制"),
|
||||
language: z.enum(["typescript", "javascript", "tsx", "jsx", "css", "html"]),
|
||||
});
|
||||
|
||||
export const SubmitSolutionRequestSchema = z.object({
|
||||
task_id: UUIDSchema,
|
||||
/** 接案時取得的冪等憑證,防止重複提交 */
|
||||
claim_token: z.string().uuid(),
|
||||
deliverables: z.object({
|
||||
files: z
|
||||
.array(DeliverableFileSchema)
|
||||
.min(1, "至少需要提交一個檔案")
|
||||
.max(20, "單次提交不得超過 20 個檔案"),
|
||||
notes: z.string().max(1000).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SubmitSolutionResponseSchema = z.object({
|
||||
task_id: UUIDSchema,
|
||||
submission_id: UUIDSchema,
|
||||
status: z.literal(TaskStatus.VERIFYING),
|
||||
/** Judge 預計完成時間(ISO 8601) */
|
||||
estimated_judge_complete_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Judge Result Schema(E2B 沙盒回傳)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const JudgeTestResultSchema = z.object({
|
||||
name: z.string(),
|
||||
status: z.enum([
|
||||
JudgeTestStatus.PASSED,
|
||||
JudgeTestStatus.FAILED,
|
||||
JudgeTestStatus.SKIPPED,
|
||||
]),
|
||||
duration_ms: NonNegativeIntSchema,
|
||||
logs: z.string().optional(),
|
||||
assertion_diff: z.string().optional(),
|
||||
});
|
||||
|
||||
export const JudgeResultSchema = z.object({
|
||||
attempt_id: UUIDSchema,
|
||||
task_id: UUIDSchema,
|
||||
submission_id: UUIDSchema,
|
||||
overall_result: z.enum([
|
||||
JudgeOverallResult.PASS,
|
||||
JudgeOverallResult.FAIL,
|
||||
JudgeOverallResult.TIMEOUT,
|
||||
]),
|
||||
tests: z.array(JudgeTestResultSchema),
|
||||
artifacts: z
|
||||
.object({
|
||||
screenshot_url: z.string().url().optional(),
|
||||
logs_url: z.string().url().optional(),
|
||||
coverage_summary: z.string().optional(),
|
||||
diff_url: z.string().url().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** 失敗類型(overall_result=fail/timeout 時必填) */
|
||||
error_classification: z
|
||||
.enum([
|
||||
JudgeErrorClassification.TEST_FAIL,
|
||||
JudgeErrorClassification.LINT_FAIL,
|
||||
JudgeErrorClassification.TIMEOUT,
|
||||
JudgeErrorClassification.RESOURCE_EXHAUSTED,
|
||||
JudgeErrorClassification.ENVIRONMENT_ERROR,
|
||||
JudgeErrorClassification.NETWORK_DENIED,
|
||||
JudgeErrorClassification.SANDBOX_CRASH,
|
||||
JudgeErrorClassification.TEST_SETUP_FAIL,
|
||||
JudgeErrorClassification.ENV_MISCONFIG,
|
||||
])
|
||||
.optional(),
|
||||
/** 供 Builder/Scout 質量分數模型使用的錯誤指紋 */
|
||||
error_signature: z.string().optional(),
|
||||
/** 若為 true,後端可以安排重試(不計入 Agent 失敗分數) */
|
||||
retryable: z.boolean(),
|
||||
resource_usage: z.object({
|
||||
cpu_ms: NonNegativeIntSchema,
|
||||
mem_peak_mb: NonNegativeIntSchema,
|
||||
io_bytes: NonNegativeIntSchema,
|
||||
}),
|
||||
judge_completed_at: z.string().datetime(),
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Settlement Ledger Schema(對帳台帳)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const SettlementLedgerEntrySchema = z.object({
|
||||
id: CUIDSchema,
|
||||
task_id: UUIDSchema,
|
||||
agent_id: z.string(),
|
||||
human_client_id: z.string(),
|
||||
/** Stripe 冪等 key:格式 "{task_id}_{phase}_{attempt}" */
|
||||
idempotency_key: z.string().min(10).max(255),
|
||||
phase: z.enum([
|
||||
SettlementPhase.AUTH_HOLD,
|
||||
SettlementPhase.CAPTURE,
|
||||
SettlementPhase.RELEASE,
|
||||
SettlementPhase.REFUND,
|
||||
SettlementPhase.PAYOUT,
|
||||
SettlementPhase.DISPUTE,
|
||||
SettlementPhase.CORRECTION,
|
||||
]),
|
||||
capture_mode: z.enum([
|
||||
SettlementCaptureMode.STRIPE_AUTH_CAPTURE,
|
||||
SettlementCaptureMode.BASE_SMART_CONTRACT,
|
||||
]),
|
||||
/** Stripe object ID(payment_intent_id / charge_id / refund_id) */
|
||||
stripe_object_id: z.string().optional(),
|
||||
amount: MoneyAmountSchema,
|
||||
currency: z.enum([SupportedCurrency.USD, SupportedCurrency.TWD]),
|
||||
request_payload_hash: z.string(),
|
||||
response_status: z.string(),
|
||||
http_status: z.number().int().min(100).max(599),
|
||||
attempt: PositiveIntSchema,
|
||||
source: z.enum(["api", "webhook", "manual_replay"]),
|
||||
/** 分潤快照(capture 時寫入,不得事後修改) */
|
||||
split: z
|
||||
.object({
|
||||
platform_amount: MoneyAmountSchema,
|
||||
builder_amount: MoneyAmountSchema,
|
||||
scout_amount: MoneyAmountSchema.optional(),
|
||||
platform_rate: z.number().min(0).max(1),
|
||||
builder_rate: z.number().min(0).max(1),
|
||||
scout_rate: z.number().min(0).max(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Lead Schema(Scout 導流任務草案)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const LeadSchema = z.object({
|
||||
lead_id: UUIDSchema,
|
||||
scout_agent_id: z.string().optional(),
|
||||
/** Scout 的 affiliate token,用於歸因 */
|
||||
affiliate_token: z.string().uuid().optional(),
|
||||
attribution_model: z.enum([
|
||||
AttributionModel.LAST_CLICK,
|
||||
AttributionModel.FIRST_CLICK,
|
||||
AttributionModel.LINEAR,
|
||||
]),
|
||||
status: z.enum([
|
||||
LeadStatus.DRAFT,
|
||||
LeadStatus.CONFIRMED,
|
||||
LeadStatus.PAYMENT_AUTHORIZED,
|
||||
LeadStatus.TASK_CREATED,
|
||||
LeadStatus.EXPIRED,
|
||||
LeadStatus.CANCELLED,
|
||||
]),
|
||||
/** 需求者原始需求(純文字,不超過 500 字) */
|
||||
raw_requirement: z.string().min(10).max(500),
|
||||
/** AI 生成的 PRD 草稿(JSON) */
|
||||
prd_draft: z.record(z.string(), z.unknown()).optional(),
|
||||
/** 付款連結(Stripe Checkout URL) */
|
||||
payment_link: z.string().url().optional(),
|
||||
/** 付款連結 TTL 過期時間 */
|
||||
payment_link_expires_at: z.string().datetime().optional(),
|
||||
/** 成功後建立的正式 task_id */
|
||||
task_id: UUIDSchema.optional(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// list_open_tasks Response Schema
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const ListOpenTasksRequestSchema = z.object({
|
||||
skills: z
|
||||
.array(z.string().min(1).max(50))
|
||||
.min(1, "至少需要指定一個技能"),
|
||||
limit: z.number().int().min(1).max(20).default(5),
|
||||
difficulty: z
|
||||
.enum([
|
||||
TaskDifficulty.HELLO_WORLD,
|
||||
TaskDifficulty.COMPONENT,
|
||||
TaskDifficulty.VIEW,
|
||||
TaskDifficulty.EPIC,
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** 精簡版 Task(list 用,避免暴露內部欄位) */
|
||||
export const TaskSummarySchema = TaskBountySchema.pick({
|
||||
task_id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
difficulty: true,
|
||||
reward: true,
|
||||
required_stack: true,
|
||||
scope_clarity_score: true,
|
||||
created_at: true,
|
||||
}).extend({
|
||||
description_preview: z.string().max(200),
|
||||
});
|
||||
|
||||
export const ListOpenTasksResponseSchema = z.object({
|
||||
tasks: z.array(TaskSummarySchema),
|
||||
total_open: z.number().int().nonnegative(),
|
||||
/** 任務池是否告警(供 MCP client 顯示警示) */
|
||||
stockout_warning: z.boolean(),
|
||||
});
|
||||
146
packages/contracts/src/types/index.ts
Normal file
146
packages/contracts/src/types/index.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @agent-bounty/contracts — TypeScript Types
|
||||
*
|
||||
* 從 Zod schema 推導出的 TypeScript 型別。
|
||||
* 永遠不要手寫這些型別——只從 z.infer 推導,確保型別與 schema 永遠同步。
|
||||
*/
|
||||
|
||||
import type { z } from "zod";
|
||||
import type {
|
||||
TaskBountySchema,
|
||||
TaskSummarySchema,
|
||||
AcceptanceCriteriaSchema,
|
||||
ClaimTaskRequestSchema,
|
||||
ClaimTaskResponseSchema,
|
||||
SubmitSolutionRequestSchema,
|
||||
SubmitSolutionResponseSchema,
|
||||
JudgeResultSchema,
|
||||
JudgeTestResultSchema,
|
||||
SettlementLedgerEntrySchema,
|
||||
LeadSchema,
|
||||
ListOpenTasksRequestSchema,
|
||||
ListOpenTasksResponseSchema,
|
||||
} from "../schemas/index.js";
|
||||
import type { VWError } from "../errors/index.js";
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Task
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export type TaskBounty = z.infer<typeof TaskBountySchema>;
|
||||
export type TaskSummary = z.infer<typeof TaskSummarySchema>;
|
||||
export type AcceptanceCriteria = z.infer<typeof AcceptanceCriteriaSchema>;
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// MCP Tool I/O
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export type ClaimTaskRequest = z.infer<typeof ClaimTaskRequestSchema>;
|
||||
export type ClaimTaskResponse = z.infer<typeof ClaimTaskResponseSchema>;
|
||||
export type SubmitSolutionRequest = z.infer<typeof SubmitSolutionRequestSchema>;
|
||||
export type SubmitSolutionResponse = z.infer<typeof SubmitSolutionResponseSchema>;
|
||||
export type ListOpenTasksRequest = z.infer<typeof ListOpenTasksRequestSchema>;
|
||||
export type ListOpenTasksResponse = z.infer<typeof ListOpenTasksResponseSchema>;
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Judge
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export type JudgeResult = z.infer<typeof JudgeResultSchema>;
|
||||
export type JudgeTestResult = z.infer<typeof JudgeTestResultSchema>;
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Settlement
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export type SettlementLedgerEntry = z.infer<typeof SettlementLedgerEntrySchema>;
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Lead / Scout
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export type Lead = z.infer<typeof LeadSchema>;
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 通用 API Response Envelope
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/** 統一 API 成功回應格式 */
|
||||
export interface ApiSuccess<T> {
|
||||
ok: true;
|
||||
data: T;
|
||||
/** 請求追蹤 ID,供 SRE 查 log */
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
/** 統一 API 錯誤回應格式 */
|
||||
export interface ApiFailure {
|
||||
ok: false;
|
||||
error: VWError;
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure;
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// State Machine 合法轉換圖(型別層面防護)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
import type { TaskStatus } from "../enums/index.js";
|
||||
|
||||
/**
|
||||
* 合法的狀態轉換對。
|
||||
* 後端 API 層應驗證每次轉換是否在此集合內。
|
||||
*/
|
||||
export type TaskStateTransition =
|
||||
| { from: "OPEN"; to: "EXECUTING" }
|
||||
| { from: "OPEN"; to: "CANCELLED" }
|
||||
| { from: "EXECUTING"; to: "VERIFYING" }
|
||||
| { from: "EXECUTING"; to: "OPEN" } // Redis TTL timeout rollback
|
||||
| { from: "EXECUTING"; to: "CANCELLED" }
|
||||
| { from: "EXECUTING"; to: "FAILED" } // agent_abandon
|
||||
| { from: "VERIFYING"; to: "COMPLETED" }
|
||||
| { from: "VERIFYING"; to: "FAILED_RETRYABLE" }
|
||||
| { from: "VERIFYING"; to: "FAILED" }
|
||||
| { from: "FAILED_RETRYABLE"; to: "OPEN" }
|
||||
| { from: "FAILED_RETRYABLE"; to: "FAILED" }
|
||||
| { from: "FAILED"; to: "OPEN" }
|
||||
| { from: "FAILED"; to: "ARCHIVED" }
|
||||
| { from: "FAILED"; to: "DISPUTED" }
|
||||
| { from: "COMPLETED"; to: "DISPUTED" }
|
||||
| { from: "COMPLETED"; to: "PAYOUT_READY" }
|
||||
| { from: "PAYOUT_READY"; to: "PAYOUT_SETTLED" }
|
||||
| { from: "PAYOUT_SETTLED"; to: "ARCHIVED" }
|
||||
| { from: "DISPUTED"; to: "OPEN" }
|
||||
| { from: "DISPUTED"; to: "COMPLETED" }
|
||||
| { from: "DISPUTED"; to: "REFUND_PENDING" }
|
||||
| { from: "DISPUTED"; to: "ARCHIVED" }
|
||||
| { from: "REFUND_PENDING"; to: "ARCHIVED" }
|
||||
| { from: "CANCELLED"; to: "ARCHIVED" };
|
||||
|
||||
/** 取得從某狀態出發的合法下一狀態集合(runtime 用) */
|
||||
export function getAllowedNextStatuses(current: TaskStatus): TaskStatus[] {
|
||||
const map: Partial<Record<TaskStatus, TaskStatus[]>> = {
|
||||
OPEN: ["EXECUTING", "CANCELLED"],
|
||||
EXECUTING: ["VERIFYING", "OPEN", "CANCELLED", "FAILED"],
|
||||
VERIFYING: ["COMPLETED", "FAILED_RETRYABLE", "FAILED"],
|
||||
FAILED_RETRYABLE: ["OPEN", "FAILED"],
|
||||
FAILED: ["OPEN", "ARCHIVED", "DISPUTED"],
|
||||
COMPLETED: ["DISPUTED", "PAYOUT_READY"],
|
||||
PAYOUT_READY: ["PAYOUT_SETTLED"],
|
||||
PAYOUT_SETTLED: ["ARCHIVED"],
|
||||
DISPUTED: ["OPEN", "COMPLETED", "REFUND_PENDING", "ARCHIVED"],
|
||||
REFUND_PENDING: ["ARCHIVED"],
|
||||
CANCELLED: ["ARCHIVED"],
|
||||
ARCHIVED: [],
|
||||
};
|
||||
return map[current] ?? [];
|
||||
}
|
||||
|
||||
/** 驗證某狀態轉換是否合法 */
|
||||
export function isValidStateTransition(
|
||||
from: TaskStatus,
|
||||
to: TaskStatus
|
||||
): boolean {
|
||||
return getAllowedNextStatuses(from).includes(to);
|
||||
}
|
||||
43
packages/contracts/tests/schemas.test.ts
Normal file
43
packages/contracts/tests/schemas.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ListOpenTasksRequestSchema, ClaimTaskRequestSchema, TaskDifficulty } from "../src";
|
||||
|
||||
describe("Contracts Zod Validation", () => {
|
||||
it("should validate a correct ListOpenTasks payload", () => {
|
||||
const payload = {
|
||||
skills: ["React", "TypeScript"],
|
||||
max_difficulty: TaskDifficulty.INTERMEDIATE,
|
||||
limit: 10,
|
||||
};
|
||||
const result = ListOpenTasksRequestSchema.safeParse(payload);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid ListOpenTasks payload", () => {
|
||||
const payload = {
|
||||
// limit is a string instead of number
|
||||
limit: "10",
|
||||
};
|
||||
const result = ListOpenTasksRequestSchema.safeParse(payload);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should validate a correct ClaimTask payload", () => {
|
||||
const payload = {
|
||||
task_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
developer_wallet: "0x1234567890123456789012345678901234567890",
|
||||
};
|
||||
const result = ClaimTaskRequestSchema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
console.error(result.error);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid ClaimTask payload", () => {
|
||||
const payload = {
|
||||
task_id: "not-a-uuid",
|
||||
};
|
||||
const result = ClaimTaskRequestSchema.safeParse(payload);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
19
packages/contracts/tsconfig.json
Normal file
19
packages/contracts/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
72
packages/mcp-server/README.md
Normal file
72
packages/mcp-server/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Agent Bounty Monetization MCP Server
|
||||
|
||||
This package implements the Model Context Protocol (MCP) server for the Agent Bounty Protocol. It exposes tools that allow AI agents to interact with the VibeWork/Agent Bounty platform, such as fetching open tasks, claiming them, and submitting solutions.
|
||||
|
||||
## Local Testing & Development
|
||||
|
||||
You can test this MCP server locally by running it in `stdio` mode and connecting it to compatible clients like **Claude Desktop** or **Cursor**.
|
||||
|
||||
### 1. Build the Server
|
||||
|
||||
Before testing, ensure the monorepo dependencies are installed and the server is built:
|
||||
|
||||
```bash
|
||||
# In the root of the monorepo
|
||||
pnpm install
|
||||
pnpm -r build
|
||||
```
|
||||
|
||||
The compiled output will be generated in `dist/index.js`.
|
||||
|
||||
---
|
||||
|
||||
### 2. Connect to Claude Desktop
|
||||
|
||||
To add this MCP server to Claude Desktop, you need to modify your Claude config file.
|
||||
|
||||
1. Open your Claude Desktop configuration file:
|
||||
- **Mac**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
|
||||
2. Add the following entry to the `mcpServers` object, replacing `<ABSOLUTE_PATH_TO_MONOREPO>` with the absolute path to your `agent-bounty-protocol` folder:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-bounty-mcp": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"<ABSOLUTE_PATH_TO_MONOREPO>/packages/mcp-server/dist/index.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Restart Claude Desktop completely. The tools (`list_open_tasks`, `claim_task`, etc.) should now be available for Claude to use.
|
||||
|
||||
---
|
||||
|
||||
### 3. Connect to Cursor
|
||||
|
||||
To test inside Cursor's Composer or Chat:
|
||||
|
||||
1. Open Cursor Settings.
|
||||
2. Navigate to **Features** -> **MCP**.
|
||||
3. Click **+ Add new MCP server**.
|
||||
4. Configure as follows:
|
||||
- **Type**: `command`
|
||||
- **Name**: `agent-bounty-mcp`
|
||||
- **Command**: `node <ABSOLUTE_PATH_TO_MONOREPO>/packages/mcp-server/dist/index.js`
|
||||
*(Make sure to use the absolute path to `dist/index.js`)*
|
||||
5. Save and refresh. Cursor should now detect the tools.
|
||||
|
||||
### Development Mode (Watch)
|
||||
|
||||
If you are actively developing the server and want it to recompile automatically, you can run:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This will run TypeScript in watch mode. Note that Claude Desktop/Cursor will still need to be refreshed or restarted to pick up structural changes to the tools.
|
||||
29
packages/mcp-server/package.json
Normal file
29
packages/mcp-server/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@agent-bounty/mcp-server",
|
||||
"version": "0.1.0",
|
||||
"description": "Agent Bounty Monetization MCP Server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"agent-bounty-mcp": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"dev": "tsc --project tsconfig.json --watch",
|
||||
"test": "vitest run",
|
||||
"start": "node ./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agent-bounty/contracts": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"zod": "^4.4.3",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^20",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
158
packages/mcp-server/src/index.ts
Normal file
158
packages/mcp-server/src/index.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express from "express";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ErrorCode,
|
||||
McpError,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
MCPToolName,
|
||||
ListOpenTasksRequestSchema,
|
||||
ClaimTaskRequestSchema,
|
||||
SubmitSolutionRequestSchema,
|
||||
} from "@agent-bounty/contracts";
|
||||
import { z } from "zod";
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "agent-bounty-mcp",
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
import "dotenv/config";
|
||||
import { proxyToBackend } from "./proxy.js";
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL;
|
||||
const API_KEY = process.env.API_KEY;
|
||||
|
||||
if (!API_BASE_URL || !API_KEY) {
|
||||
console.error("Warning: API_BASE_URL or API_KEY is not set. The MCP server will fail to proxy requests.");
|
||||
}
|
||||
|
||||
// The proxyToBackend function has been extracted to proxy.js
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 工具註冊
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: MCPToolName.LIST_OPEN_TASKS,
|
||||
description: "List available tasks on Agent Bounty based on your skills. Fetches open bounties with their reward amount and acceptance criteria.",
|
||||
inputSchema: zodToJsonSchema(ListOpenTasksRequestSchema as any),
|
||||
},
|
||||
{
|
||||
name: MCPToolName.CLAIM_TASK,
|
||||
description: "Claim a specific task to start working on it. This places a financial Auth-Hold lock on the task. You must finish it within 1 hour.",
|
||||
inputSchema: zodToJsonSchema(ClaimTaskRequestSchema as any),
|
||||
},
|
||||
{
|
||||
name: MCPToolName.SUBMIT_SOLUTION,
|
||||
description: "Submit your code solution for an executing task. The solution will be tested in the sandbox environment. You must provide all requested files.",
|
||||
inputSchema: zodToJsonSchema(SubmitSolutionRequestSchema as any),
|
||||
},
|
||||
{
|
||||
name: MCPToolName.CHECK_PAYOUT_STATUS,
|
||||
description: "Check the payout and settlement status of a completed task. Returns tracking information from the reconciliation ledger.",
|
||||
inputSchema: zodToJsonSchema(
|
||||
z.object({
|
||||
task_id: z.string().uuid("Invalid task_id"),
|
||||
developer_wallet: z.string().optional(),
|
||||
}) as any
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 工具路由與後端呼叫(MVP 階段先回傳 Mock 結構)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case MCPToolName.LIST_OPEN_TASKS: {
|
||||
const parsed = ListOpenTasksRequestSchema.parse(args);
|
||||
const data = await proxyToBackend("/api/mcp/list_open_tasks", parsed, API_BASE_URL, API_KEY);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case MCPToolName.CLAIM_TASK: {
|
||||
const parsed = ClaimTaskRequestSchema.parse(args);
|
||||
const data = await proxyToBackend("/api/mcp/claim_task", parsed, API_BASE_URL, API_KEY);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case MCPToolName.SUBMIT_SOLUTION: {
|
||||
const parsed = SubmitSolutionRequestSchema.parse(args);
|
||||
const data = await proxyToBackend("/api/mcp/submit_solution", parsed, API_BASE_URL, API_KEY);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case MCPToolName.CHECK_PAYOUT_STATUS: {
|
||||
const parsed = z.object({
|
||||
task_id: z.string().uuid("Invalid task_id"),
|
||||
developer_wallet: z.string().optional(),
|
||||
}).parse(args);
|
||||
const data = await proxyToBackend("/api/mcp/check_payout_status", parsed, API_BASE_URL, API_KEY);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const zodErr = error as any;
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Invalid arguments: ${zodErr.errors.map((e: any) => `${e.path.join(".")}: ${e.message}`).join(", ")}`
|
||||
);
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
throw new McpError(ErrorCode.InternalError, msg);
|
||||
}
|
||||
});
|
||||
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
|
||||
const app = express();
|
||||
|
||||
let transport: SSEServerTransport;
|
||||
|
||||
app.get("/sse", async (req, res) => {
|
||||
transport = new SSEServerTransport("/message", res as any);
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
app.post("/message", async (req, res) => {
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req as any, res as any);
|
||||
} else {
|
||||
res.status(500).send("Transport not initialized");
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Agent Bounty Monetization MCP Server running on SSE at http://localhost:${PORT}`);
|
||||
});
|
||||
26
packages/mcp-server/src/proxy.ts
Normal file
26
packages/mcp-server/src/proxy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export async function proxyToBackend(
|
||||
endpoint: string,
|
||||
payload: any,
|
||||
baseUrl?: string,
|
||||
apiKey?: string
|
||||
) {
|
||||
if (!baseUrl) throw new Error("API_BASE_URL is not configured.");
|
||||
|
||||
const url = `${baseUrl.replace(/\/$/, "")}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey || ""}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Backend error (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
52
packages/mcp-server/tests/proxy.test.ts
Normal file
52
packages/mcp-server/tests/proxy.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { proxyToBackend } from "../src/proxy";
|
||||
|
||||
describe("proxyToBackend", () => {
|
||||
const MOCK_BASE_URL = "http://localhost:3000";
|
||||
const MOCK_API_KEY = "test-key";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should make a POST request with correct headers and body", async () => {
|
||||
const mockResponse = { success: true, data: "test" };
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const payload = { test: 123 };
|
||||
const result = await proxyToBackend("/api/test", payload, MOCK_BASE_URL, MOCK_API_KEY);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("http://localhost:3000/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer test-key",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should throw an error if API_BASE_URL is not provided", async () => {
|
||||
await expect(proxyToBackend("/api/test", {}, "", MOCK_API_KEY)).rejects.toThrow(
|
||||
"API_BASE_URL is not configured."
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if response is not ok", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "Internal Server Error",
|
||||
});
|
||||
|
||||
await expect(
|
||||
proxyToBackend("/api/test", {}, MOCK_BASE_URL, MOCK_API_KEY)
|
||||
).rejects.toThrow("Backend error (500): Internal Server Error");
|
||||
});
|
||||
});
|
||||
15
packages/mcp-server/tsconfig.json
Normal file
15
packages/mcp-server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
6140
pnpm-lock.yaml
generated
Normal file
6140
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "apps/*"
|
||||
Reference in New Issue
Block a user