agents.js 5.33 KB
const TABLE = 'vk_agents'

/** Agent 的全部状态;消费方 switch 必须穷举。与 DB CHECK `ck_vk_agents_status` 一致。 */
export const AGENT_STATUSES = Object.freeze(['pending_claim', 'active', 'suspended'])
export const AGENT_ROLES = Object.freeze(['company', 'member'])

/**
 * @typedef {{
 *   id: string,
 *   wallet_user_id: string,
 *   owner_user_id: string|null,
 *   guild_id: string|null,
 *   role: 'company'|'member',
 *   status: 'pending_claim'|'active'|'suspended',
 *   name: string,
 *   description: string|null,
 *   skills: string[],
 *   level: number,
 *   xp: number,
 *   claimed_at: Date|null,
 *   created_at: Date,
 * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at 为 null,其余状态三者必有。
 */

/**
 * DB 行 → Agent 的唯一构造点。按状态保证必有字段,非法即 throw。
 * @param {Record<string, any>} row
 * @returns {Agent}
 */
export function agentFromRow(row) {
  const id = requiredString(row.id, 'id', row.id)
  if (!AGENT_ROLES.includes(row.role)) throw new Error(`vk_agents.role invalid: ${row.role}, id: ${id}`)
  switch (row.status) {
    case 'pending_claim':
      if (row.owner_user_id !== null || row.guild_id !== null || row.claimed_at !== null) throw new Error(`vk_agents pending_claim row has claim fields, id: ${id}`)
      break
    case 'active':
    case 'suspended':
      requiredString(row.owner_user_id, 'owner_user_id', id)
      requiredString(row.guild_id, 'guild_id', id)
      if (!(row.claimed_at instanceof Date)) throw new Error(`vk_agents.claimed_at missing, id: ${id}`)
      break
    default:
      throw new Error(`Unhandled status: ${row.status}, id: ${id}`)
  }
  if (!(row.created_at instanceof Date)) throw new Error(`vk_agents.created_at missing, id: ${id}`)
  if (!Array.isArray(row.skills) || row.skills.some((skill) => typeof skill !== 'string')) throw new Error(`vk_agents.skills invalid, id: ${id}`)
  if (!Number.isSafeInteger(row.level) || row.level < 1) throw new Error(`vk_agents.level invalid, id: ${id}`)
  if (!Number.isSafeInteger(row.xp) || row.xp < 0) throw new Error(`vk_agents.xp invalid, id: ${id}`)
  return {
    id,
    wallet_user_id: requiredString(row.wallet_user_id, 'wallet_user_id', id),
    owner_user_id: row.owner_user_id,
    guild_id: row.guild_id,
    role: row.role,
    status: row.status,
    name: requiredString(row.name, 'name', id),
    description: row.description === null ? null : requiredString(row.description, 'description', id),
    skills: row.skills,
    level: row.level,
    xp: row.xp,
    claimed_at: row.claimed_at,
    created_at: row.created_at,
  }
}

/**
 * vk_agents 的读写。写方法的 `runner` 传调用方的事务,使 Agent 行与认领、记账一起提交。
 */
export function createAgents() {
  /**
   * 新注册的 Agent:待认领、员工角色、1 级。
   * @param {object} _ctx
   * @param {import('knex').Knex} runner
   * @param {{ id: string, wallet_user_id: string, name: string, description: string|null, skills: string[], owner_email: string|null, created_at: Date }} input
   */
  async function insertPending(_ctx, runner, input) {
    await runner(TABLE).insert({
      id: input.id,
      wallet_user_id: input.wallet_user_id,
      role: 'member',
      status: 'pending_claim',
      name: input.name,
      description: input.description,
      skills: JSON.stringify(input.skills),
      owner_email: input.owner_email,
      level: 1,
      xp: 0,
      events_cursor: 0,
      created_at: input.created_at,
    })
  }

  /**
   * @param {object} _ctx
   * @param {import('knex').Knex} runner
   * @param {{ agent_id: string }} input
   * @returns {Promise<Agent>}
   */
  async function getById(_ctx, runner, { agent_id }) {
    const row = await runner(TABLE).where({ id: agent_id }).first()
    if (row === undefined) throw new Error(`vk_agents row missing, id: ${agent_id}`)
    return agentFromRow(row)
  }

  /**
   * 认领:pending_claim → active。行不在 pending_claim 即 throw(调用方已锁认领凭证,不应发生)。
   * @param {object} _ctx
   * @param {import('knex').Knex} runner
   * @param {{ agent_id: string, owner_user_id: string, guild_id: string, claimed_at: Date }} input
   */
  async function activate(_ctx, runner, { agent_id, owner_user_id, guild_id, claimed_at }) {
    const updated = await runner(TABLE).where({ id: agent_id, status: 'pending_claim' }).update({ status: 'active', owner_user_id, guild_id, claimed_at, updated_at: claimed_at })
    if (updated !== 1) throw new Error(`vk_agents activate expected pending_claim row, id: ${agent_id}`)
  }

  /**
   * @param {object} _ctx
   * @param {import('knex').Knex} runner
   * @param {{ agent_id: string, description?: string, skills?: string[] }} input
   */
  async function updateProfile(_ctx, runner, { agent_id, description, skills }) {
    await runner(TABLE).where({ id: agent_id }).update({
      ...(description === undefined ? {} : { description }),
      ...(skills === undefined ? {} : { skills: JSON.stringify(skills) }),
      updated_at: new Date(),
    })
  }

  return { insertPending, getById, activate, updateProfile }
}

/**
 * @param {unknown} value
 * @param {string} field
 * @param {unknown} id
 * @returns {string}
 */
function requiredString(value, field, id) {
  if (typeof value === 'string' && value.length > 0) return value
  throw new Error(`vk_agents.${field} invalid, id: ${String(id)}`)
}