chore: initial commit with Phase 0 setup

This commit is contained in:
OG T
2026-06-06 22:55:45 +08:00
commit 9e79e58f87
56 changed files with 16088 additions and 0 deletions

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

82
README.md Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
@AGENTS.md

36
apps/web/README.md Normal file
View 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.

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

14
apps/web/prisma.config.ts Normal file
View 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"],
},
});

View 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
View 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

View 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
View 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

View 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

View 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
View 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());

View 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,
});
}

View 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 });
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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;
}

View 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
View 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>
);
}

View 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>
);
}

View 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}`);
}

View 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';&#10;&#10;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
View 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,
},
});
}

View 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;

View 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;

View 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
View 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
View 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
View 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"
}
}

View 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"
}
}

View File

@@ -0,0 +1,230 @@
/**
* @agent-bounty/contracts — Enums
*
* 所有狀態機 enum、分類常數的唯一真實來源。
* MCP server、後端 API、前端 UI 都必須 import 這裡,禁止各自重新定義。
*/
// ─────────────────────────────────────────────
// Task 任務生命週期狀態機
// ─────────────────────────────────────────────
/**
* TaskStatus任務在系統中的完整生命週期。
*
* 狀態轉換規則前後端、MCP、Judge Agent 必遵守):
*
* OPEN
* → EXECUTING (claim_taskDB 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];

View 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 ConditionSELECT 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;
}

View 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";

View 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_scoreAI 評估任務清晰度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,
]),
/** 由平台提供的最小測試檔內容stringAgent 不得修改此欄位 */
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 核心 SchemaOpen 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 SchemaE2B 沙盒回傳)
// ─────────────────────────────────────────────
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 IDpayment_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 SchemaScout 導流任務草案)
// ─────────────────────────────────────────────
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(),
});
/** 精簡版 Tasklist 用,避免暴露內部欄位) */
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(),
});

View 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);
}

View 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);
});
});

View 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"]
}

View 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.

View 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"
}
}

View 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}`);
});

View 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();
}

View 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");
});
});

View 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

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- "packages/*"
- "apps/*"