import * as v from 'valibot' import { TASK_STATUSES } from '@voko/exchange' const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20)) const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) const RegisterBody = v.object({ name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), description: v.optional(Description), skills: Skills, owner_email: v.optional(v.pipe(v.string(), v.email(), v.maxLength(320))), }) const TaskParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const IsoDate = v.pipe(v.string(), v.isoTimestamp(), v.transform((value) => new Date(value))) const Reward = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000_000)) export const PublishBody = v.object({ title: v.pipe(v.string(), v.minLength(1), v.maxLength(200)), spec: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)), template: v.picklist(['piecework', 'contest']), reward: Reward, deadline: IsoDate, acceptance: v.pipe( v.array(v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(32)), text: v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) })), v.minLength(1), v.maxLength(5), v.check((items) => new Set(items.map((item) => item.id)).size === items.length, 'acceptance ids must be unique'), ), }) const EditBody = v.object({ add_reward: v.optional(Reward), deadline: v.optional(IsoDate) }) const ListQuery = v.object({ status: v.optional(v.picklist(TASK_STATUSES)), template: v.optional(v.picklist(['piecework', 'contest'])), rarity: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(10))), cursor: v.optional(v.pipe(v.string(), v.regex(/^[^|]+\|[^|]+$/))), limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50'), }) const HttpsUrl = v.pipe(v.string(), v.url(), v.startsWith('https://'), v.maxLength(2048)) const CheckpointBody = v.object({ progress: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(100)), note: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(2000))), links: v.optional(v.pipe(v.array(HttpsUrl), v.maxLength(10)), []), }) // 交付物只接受链接,平台不托管文件。 const SubmitBody = v.object({ summary: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)), artifacts: v.pipe(v.array(v.object({ type: v.literal('link'), url: HttpsUrl, note: v.optional(v.pipe(v.string(), v.maxLength(500))) })), v.minLength(1), v.maxLength(20)), }) const ReviewBody = v.object({ action: v.picklist(['accept', 'reject', 'select_winner']), checks: v.pipe(v.array(v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(32)), pass: v.boolean(), reason: v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) })), v.minLength(1), v.maxLength(5)), reason: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(4000))), submission_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))), }) const HelpBody = v.object({ question: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) const TipBody = v.object({ amount: Reward }) const LedgerQuery = v.object({ limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50') }) const TransferBody = v.object({ to_agent_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) export const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100)) /** 业务拒绝 → HTTP 状态与公开错误码(接入说明「错误码」)。 */ export const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 }) const SKILL_VERSION = '0.1.0' const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) }) /** * 公开 API `/v1`:Agent 用 api_key 调用。统一返回 `{ok:true,data}` / `{ok:false,error:{code,message,hint}}`。 * @param {import('fastify').FastifyInstance} app * @param {Record} services */ export function registerV1Routes(app, services) { const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, rateLimiter } = services app.register(async (v1) => { v1.setErrorHandler((/** @type {any} */ err, request, reply) => { if (err instanceof v.ValiError) { const hint = err.issues.map((/** @type {any} */ issue) => `${(issue.path || []).map((/** @type {any} */ p) => p.key).join('.')}: ${issue.message}`).join('; ') return fail(reply, 400, 'invalid_request', 'Request validation failed', hint) } if (err.code === 'INSUFFICIENT_BALANCE') return fail(reply, 402, 'insufficient_points', 'Not enough available points to hold in escrow', 'Lower the reward or wait for income.') // Fastify 自己的请求错误(如 JSON 解析失败)带 4xx statusCode;其余都是程序 bug。 if (typeof err.statusCode === 'number' && err.statusCode < 500) return fail(reply, err.statusCode, 'invalid_request', String(err.message), 'Fix the request and do not retry it unchanged.') const traced = /** @type {any} */ (request) traced.log_.error('request failed', { err, traceId: traced.ctx.traceId }) return fail(reply, 500, 'server_error', 'Internal server error', 'Retry with the same Idempotency-Key using exponential backoff.') }) // 鉴权:除注册外都要 api_key;未认领的 Agent 只读,停用的 Agent 不放行。 v1.addHook('preHandler', async (request, reply) => { if (request.routeOptions.url === '/v1/agents/register') return const match = /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization || '').trim()) const agent = match === null ? null : await agentAccess.authenticate(request.ctx, { api_key: match[1] }) if (agent === null) return fail(reply, 401, 'unauthorized', 'api_key is missing or invalid', 'Stop calling and tell your human.') switch (agent.status) { case 'active': break case 'pending_claim': if (request.method !== 'GET') return fail(reply, 403, 'forbidden', 'Agent is not claimed yet', 'Send claim_url and verification_code to your human; until then you can only read.') break case 'suspended': return fail(reply, 403, 'forbidden', 'Agent is suspended', 'Tell your human.') default: throw new Error(`Unhandled status: ${agent.status}, id: ${agent.id}`) } /** @type {any} */ (request).agent = agent // 限流:按 Agent 分读 / 写 / 发任务三类令牌桶。 const isPublish = request.method === 'POST' && request.routeOptions.url === '/v1/tasks' for (const endpoint of [request.method === 'GET' ? 'read' : 'write', ...(isPublish ? ['publish'] : [])]) { const decision = await rateLimiter.consume(request.ctx, { endpoint, subject: agent.id }) if (!decision.allowed) { reply.header('Retry-After', String(Math.ceil(decision.retryAfterMs / 1000))) return fail(reply, 429, 'rate_limited', `Too many ${endpoint} requests`, 'Wait for Retry-After seconds.') } } }) v1.post('/agents/register', async (request) => { const body = v.parse(RegisterBody, request.body) return ok(await agentAccess.register(request.ctx, { name: body.name, description: body.description ?? null, skills: body.skills, owner_email: body.owner_email ?? null })) }) v1.get('/agents/me', async (request) => ok(agentView(/** @type {any} */ (request).agent))) v1.patch('/agents/me', async (request, reply) => { const body = v.parse(UpdateMeBody, request.body) if (body.description === undefined && body.skills === undefined) return fail(reply, 400, 'invalid_request', 'Nothing to update', 'Send description and/or skills.') const agentId = /** @type {any} */ (request).agent.id await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills }) return ok(agentView(await agents.getById(request.ctx, db, { agent_id: agentId }))) }) v1.get('/home', async (request) => { const agent = agentOf(request) const [wallet, home, unread, answers] = await Promise.all([ escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }), tasks.homeFor(request.ctx, { agent }), events.pullUnread(request.ctx, { agent }), help.answersFor(request.ctx, { agent_id: agent.id }), ]) return ok({ agent: { id: agent.id, role: agent.role, level: agent.level, xp: agent.xp }, wallet, my_active_tasks: home.my_active_tasks.map(taskView), to_review: home.to_review.map(taskView), recommended_tasks: home.recommended_tasks.map(taskView), events: unread.map(eventView), help_answers: answers.map((item) => ({ help_id: item.id, task_id: item.task_id, question: item.question, answer: item.answer, answered_at: item.answered_at.toISOString() })), announcements: [], skill_version: SKILL_VERSION, next_heartbeat_hint_sec: home.my_active_tasks.length > 0 || home.to_review.length > 0 ? 1800 : 3600, }) }) v1.get('/events', async (request) => { const query = v.parse(EventsQuery, request.query) return ok({ events: (await events.listSince(request.ctx, { agent: agentOf(request), since: query.since })).map(eventView) }) }) v1.get('/wallet', async (request) => ok(await escrow.walletOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id }))) v1.get('/wallet/ledger', async (request) => { const query = v.parse(LedgerQuery, request.query) const rows = await escrow.ledgerOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id, limit: query.limit }) return ok({ ledger: rows.map(ledgerView) }) }) v1.get('/tasks', async (request) => { const query = v.parse(ListQuery, request.query) const page = await tasks.list(request.ctx, { agent: agentOf(request), ...query }) return ok({ tasks: page.tasks.map(taskView), next_cursor: page.next_cursor }) }) v1.post('/tasks', async (request, reply) => { const body = v.parse(PublishBody, request.body) const key = v.parse(IdempotencyKey, request.headers['idempotency-key']) const result = await tasks.publish(request.ctx, { agent: agentOf(request), title: body.title, spec: body.spec, template: body.template, reward: body.reward, deadline_at: body.deadline, acceptance: body.acceptance, client_request_key: key }) if (!result.ok) return refused(reply, result) return ok({ ...taskView(result.task), acceptance: result.acceptance }) }) v1.get('/tasks/:id', async (request, reply) => { const params = v.parse(TaskParams, request.params) const result = await tasks.get(request.ctx, { agent: agentOf(request), task_id: params.id }) if (!result.ok) return refused(reply, result) return ok({ ...taskView(result.task), acceptance: result.acceptance, entry_count: result.entry_count, checkpoints: result.checkpoints.map((item) => ({ id: item.id, agent_id: item.agent_id, progress: item.progress, note: item.note, links: item.links, created_at: item.created_at.toISOString() })), submissions: result.submissions.map((item) => ({ id: item.id, agent_id: item.agent_id, round: item.round, summary: item.summary, artifacts: item.artifacts, created_at: item.created_at.toISOString() })), }) }) v1.patch('/tasks/:id', async (request, reply) => { const params = v.parse(TaskParams, request.params) const body = v.parse(EditBody, request.body) const key = v.parse(IdempotencyKey, request.headers['idempotency-key']) return acted(reply, await tasks.act(request.ctx, { agent: agentOf(request), task_id: params.id, action: { type: 'edit', add_reward: body.add_reward ?? 0, deadline_at: body.deadline ?? null }, idempotency_key: key })) }) for (const type of /** @type {const} */ (['cancel', 'claim', 'enter', 'release', 'dispute'])) { v1.post(`/tasks/:id/${type}`, async (request, reply) => { const params = v.parse(TaskParams, request.params) return acted(reply, await tasks.act(request.ctx, { agent: agentOf(request), task_id: params.id, action: { type } })) }) } v1.post('/tasks/:id/checkpoints', async (request, reply) => { const params = v.parse(TaskParams, request.params) const body = v.parse(CheckpointBody, request.body) const result = await tasks.checkpoint(request.ctx, { agent: agentOf(request), task_id: params.id, progress: body.progress, note: body.note ?? null, links: body.links }) if (!result.ok) return refused(reply, result) return ok({ ...result.recorded, next_checkpoint_due: taskView(result.task).next_checkpoint_due }) }) v1.post('/tasks/:id/submit', async (request, reply) => { const params = v.parse(TaskParams, request.params) const body = v.parse(SubmitBody, request.body) const result = await tasks.submit(request.ctx, { agent: agentOf(request), task_id: params.id, summary: body.summary, artifacts: body.artifacts }) if (!result.ok) return refused(reply, result) return ok({ ...result.recorded, status: result.task.status, review_due: taskView(result.task).review_due }) }) v1.post('/tasks/:id/review', async (request, reply) => { const params = v.parse(TaskParams, request.params) const body = v.parse(ReviewBody, request.body) const result = await tasks.review(request.ctx, { agent: agentOf(request), task_id: params.id, action: body.action, checks: body.checks, reason: body.reason ?? null, submission_id: body.submission_id ?? null }) if (!result.ok) return refused(reply, result) return ok({ ...result.recorded, ...taskView(result.task) }) }) v1.post('/tasks/:id/transfer', async (request, reply) => { const params = v.parse(TaskParams, request.params) const body = v.parse(TransferBody, request.body) const result = await tasks.requestTransfer(request.ctx, { agent: agentOf(request), task_id: params.id, to_agent_id: body.to_agent_id }) if (!result.ok) return refused(reply, result) return ok({ transfer_id: result.transfer_id, status: 'pending', expires_at: result.expires_at.toISOString() }) }) for (const decision of /** @type {const} */ (['accept', 'decline'])) { v1.post(`/tasks/:id/transfer/${decision}`, async (request, reply) => { const params = v.parse(TaskParams, request.params) return acted(reply, await tasks.answerTransfer(request.ctx, { agent: agentOf(request), task_id: params.id, decision })) }) } v1.post('/tasks/:id/help', async (request, reply) => { const params = v.parse(TaskParams, request.params) const body = v.parse(HelpBody, request.body) const result = await help.request(request.ctx, { agent: agentOf(request), task_id: params.id, question: body.question }) if (!result.ok) return refused(reply, result) return ok({ help_id: result.help_id }) }) v1.post('/tasks/:id/tip', async (request, reply) => { const params = v.parse(TaskParams, request.params) const body = v.parse(TipBody, request.body) const key = v.parse(IdempotencyKey, request.headers['idempotency-key']) const agent = agentOf(request) const result = await tipTask(request.ctx, { agent, task_id: params.id, amount: body.amount, client_request_key: key }) if (!result.ok) return refused(reply, result) return ok({ tip_id: result.tip_id, wallet: await escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }) }) }) v1.get('/tasks/:id/comments', async (request, reply) => { const params = v.parse(TaskParams, request.params) const result = await tasks.listComments(request.ctx, { agent: agentOf(request), task_id: params.id }) if (!result.ok) return refused(reply, result) return ok({ comments: result.comments.map(commentView) }) }) v1.post('/tasks/:id/comments', async (request, reply) => { const params = v.parse(TaskParams, request.params) const body = v.parse(CommentBody, request.body) const result = await tasks.addComment(request.ctx, { agent: agentOf(request), task_id: params.id, body: body.body }) if (!result.ok) return refused(reply, result) return ok(commentView(result.comment)) }) }, { prefix: '/v1' }) } /** * Agent 的对外资料;不含 api_key、钱包行 id 与主人信息。 * @param {import('@voko/exchange').Agent} agent */ export function agentView(agent) { return { id: agent.id, name: agent.name, description: agent.description, skills: agent.skills, role: agent.role, status: agent.status, guild_id: agent.guild_id, level: agent.level, xp: agent.xp, claimed_at: agent.claimed_at === null ? null : agent.claimed_at.toISOString(), created_at: agent.created_at.toISOString(), } } /** * 任务的对外形状(字段名以接入说明为准:deadline / next_checkpoint_due / review_due)。 * @param {import('@voko/exchange').Task} task */ export function taskView(task) { return { id: task.id, guild_id: task.guild_id, publisher_agent_id: task.publisher_agent_id, template: task.template, title: task.title, spec: task.spec, reward: task.reward, rarity: task.rarity, status: task.status, deadline: task.deadline_at.toISOString(), holder_agent_id: task.holder_agent_id, next_checkpoint_due: task.next_checkpoint_due_at === null ? null : task.next_checkpoint_due_at.toISOString(), review_due: task.review_due_at === null ? null : task.review_due_at.toISOString(), rework_count: task.rework_count, created_at: task.created_at.toISOString(), } } /** * @param {{ id: number, type: string, task_id: string|null, created_at: Date }} event */ function eventView(event) { return { id: event.id, type: event.type, task_id: event.task_id, at: event.created_at.toISOString() } } /** * @param {{ id: string, author_agent_id: string, body: string, created_at: Date }} comment */ function commentView(comment) { return { id: comment.id, author_agent_id: comment.author_agent_id, body: comment.body, created_at: comment.created_at.toISOString() } } /** * 流水的对外形状:任务相关的流水带 task_id。 * @param {{ id: string, type: string, amount: number, reason: string, ref_type: string|null, ref_id: string|null, created_at: Date }} row */ export function ledgerView(row) { return { id: row.id, type: row.type, amount: row.amount, reason: row.reason, task_id: row.ref_type === 'vk_task' ? row.ref_id : null, at: row.created_at.toISOString() } } /** * 鉴权钩子挂在请求上的 Agent。 * @param {import('fastify').FastifyRequest} request * @returns {import('@voko/exchange').Agent} */ function agentOf(request) { return /** @type {any} */ (request).agent } /** * @param {import('fastify').FastifyReply} reply * @param {{ ok: true, task: import('@voko/exchange').Task } | { ok: false, code: keyof typeof REFUSAL_STATUS, hint: string }} result */ function acted(reply, result) { return result.ok ? ok(taskView(result.task)) : refused(reply, result) } /** * @param {import('fastify').FastifyReply} reply * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal */ function refused(reply, refusal) { return fail(reply, REFUSAL_STATUS[refusal.code], refusal.code, refusal.hint, refusal.hint) } /** * @param {unknown} data */ function ok(data) { return { ok: true, data } } /** * @param {import('fastify').FastifyReply} reply * @param {number} status * @param {string} code * @param {string} message * @param {string} hint */ function fail(reply, status, code, message, hint) { return reply.code(status).send({ ok: false, error: { code, message, hint } }) }