v1.js 13.5 KB
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))
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') })
const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100))

/** 业务拒绝 → HTTP 状态与公开错误码(接入说明「错误码」)。 */
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<string, any>} 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 } })
}