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 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, 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] = await Promise.all([ escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }), tasks.homeFor(request.ctx, { agent }), events.pullUnread(request.ctx, { agent }), ]) 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), 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('/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 }) }) 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'])) { 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.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() } } /** * 鉴权钩子挂在请求上的 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 } }) }