diff --git a/packages/voko-exchange/package.json b/packages/voko-exchange/package.json index 6198a09..a617072 100644 --- a/packages/voko-exchange/package.json +++ b/packages/voko-exchange/package.json @@ -5,5 +5,11 @@ "type": "module", "exports": { ".": "./src/index.js" + }, + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^2.1.4" } } diff --git a/packages/voko-exchange/src/index.js b/packages/voko-exchange/src/index.js index 99e9309..1feafe4 100644 --- a/packages/voko-exchange/src/index.js +++ b/packages/voko-exchange/src/index.js @@ -1,9 +1,11 @@ /** * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。 * - * 当前只有表结构;状态机与 service 在 W1–W3 加入。 + * 当前有表结构、任务构造点与状态机纯函数;service 在 W2–W3 加入。 * * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 */ +export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' +export { transition, publishStatus, TASK_ACTIONS, MAX_ACTIVE_HOLDS } from './transition.js' export { default as exchangeDbSchema } from './db/tables/index.cjs' diff --git a/packages/voko-exchange/src/task.js b/packages/voko-exchange/src/task.js new file mode 100644 index 0000000..624248d --- /dev/null +++ b/packages/voko-exchange/src/task.js @@ -0,0 +1,175 @@ +/** 任务的全部状态;消费方 switch 必须穷举。与 DB CHECK `ck_vk_tasks_status` 一致。 */ +export const TASK_STATUSES = Object.freeze([ + 'pending_approval', + 'open', + 'claimed', + 'in_contest', + 'submitted', + 'reviewing', + 'rework', + 'disputed', + 'settled', + 'cancelled', + 'refunded', +]) + +export const TASK_TEMPLATES = Object.freeze(['piecework', 'contest']) + +/** + * @typedef {'pending_approval'|'open'|'claimed'|'in_contest'|'submitted'|'reviewing'|'rework'|'disputed'|'settled'|'cancelled'|'refunded'} TaskStatus + * + * @typedef {{ + * id: string, + * guild_id: string, + * publisher_agent_id: string, + * template: 'piecework'|'contest', + * title: string, + * spec: string, + * reward: number, + * rarity: number, + * deadline_at: Date, + * status: TaskStatus, + * client_request_key: string, + * holder_agent_id: string|null, + * next_checkpoint_due_at: Date|null, + * missed_checkpoints: number, + * rework_count: number, + * review_due_at: Date|null, + * settled_at: Date|null, + * created_at: Date, + * updated_at: Date|null, + * }} Task + */ + +/** + * DB 行 → Task 的唯一构造点。按状态保证必有字段,非法即 throw;下游读取零判空。 + * @param {Record} row + * @returns {Task} + */ +export function taskFromRow(row) { + const id = requiredString(row.id, 'id', row.id) + if (!TASK_TEMPLATES.includes(row.template)) throw new Error(`vk_tasks.template invalid: ${row.template}, id: ${id}`) + const holder = row.holder_agent_id === null ? null : requiredString(row.holder_agent_id, 'holder_agent_id', id) + const nextCheckpointDue = nullableDate(row.next_checkpoint_due_at, 'next_checkpoint_due_at', id) + const reviewDue = nullableDate(row.review_due_at, 'review_due_at', id) + const settledAt = nullableDate(row.settled_at, 'settled_at', id) + + switch (row.status) { + case 'claimed': + requirePiecework(row, id) + if (holder === null) throw new Error(`vk_tasks.holder_agent_id missing for claimed, id: ${id}`) + if (nextCheckpointDue === null) throw new Error(`vk_tasks.next_checkpoint_due_at missing for claimed, id: ${id}`) + break + case 'submitted': + requirePiecework(row, id) + if (holder === null) throw new Error(`vk_tasks.holder_agent_id missing for submitted, id: ${id}`) + if (reviewDue === null) throw new Error(`vk_tasks.review_due_at missing for submitted, id: ${id}`) + break + case 'rework': + requirePiecework(row, id) + if (holder === null) throw new Error(`vk_tasks.holder_agent_id missing for rework, id: ${id}`) + break + case 'in_contest': + requireContest(row, id) + break + case 'reviewing': + requireContest(row, id) + if (reviewDue === null) throw new Error(`vk_tasks.review_due_at missing for reviewing, id: ${id}`) + break + case 'settled': + if (settledAt === null) throw new Error(`vk_tasks.settled_at missing for settled, id: ${id}`) + break + case 'pending_approval': + case 'open': + case 'disputed': + case 'cancelled': + case 'refunded': + break + default: + throw new Error(`Unhandled status: ${row.status}, id: ${id}`) + } + if (row.template === 'contest' && holder !== null) throw new Error(`vk_tasks contest has holder, id: ${id}`) + + return { + id, + guild_id: requiredString(row.guild_id, 'guild_id', id), + publisher_agent_id: requiredString(row.publisher_agent_id, 'publisher_agent_id', id), + template: row.template, + title: requiredString(row.title, 'title', id), + spec: requiredString(row.spec, 'spec', id), + reward: integerInRange(row.reward, 1, Number.MAX_SAFE_INTEGER, 'reward', id), + rarity: integerInRange(row.rarity, 1, 10, 'rarity', id), + deadline_at: requiredDate(row.deadline_at, 'deadline_at', id), + status: row.status, + client_request_key: requiredString(row.client_request_key, 'client_request_key', id), + holder_agent_id: holder, + next_checkpoint_due_at: nextCheckpointDue, + missed_checkpoints: integerInRange(row.missed_checkpoints, 0, 1, 'missed_checkpoints', id), + rework_count: integerInRange(row.rework_count, 0, 2, 'rework_count', id), + review_due_at: reviewDue, + settled_at: settledAt, + created_at: requiredDate(row.created_at, 'created_at', id), + updated_at: nullableDate(row.updated_at, 'updated_at', id), + } +} + +/** + * @param {Record} row + * @param {string} id + */ +function requirePiecework(row, id) { + if (row.template !== 'piecework') throw new Error(`vk_tasks status ${row.status} requires piecework, id: ${id}`) +} + +/** + * @param {Record} row + * @param {string} id + */ +function requireContest(row, id) { + if (row.template !== 'contest') throw new Error(`vk_tasks status ${row.status} requires contest, id: ${id}`) +} + +/** + * @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_tasks.${field} invalid, id: ${String(id)}`) +} + +/** + * @param {unknown} value + * @param {string} field + * @param {string} id + * @returns {Date} + */ +function requiredDate(value, field, id) { + if (value instanceof Date) return value + throw new Error(`vk_tasks.${field} invalid, id: ${id}`) +} + +/** + * @param {unknown} value + * @param {string} field + * @param {string} id + * @returns {Date|null} + */ +function nullableDate(value, field, id) { + return value === null ? null : requiredDate(value, field, id) +} + +/** + * @param {unknown} value + * @param {number} min + * @param {number} max + * @param {string} field + * @param {string} id + * @returns {number} + */ +function integerInRange(value, min, max, field, id) { + if (Number.isSafeInteger(value) && /** @type {number} */ (value) >= min && /** @type {number} */ (value) <= max) return /** @type {number} */ (value) + throw new Error(`vk_tasks.${field} invalid: ${String(value)}, id: ${id}`) +} diff --git a/packages/voko-exchange/src/transition.js b/packages/voko-exchange/src/transition.js new file mode 100644 index 0000000..635a5da --- /dev/null +++ b/packages/voko-exchange/src/transition.js @@ -0,0 +1,495 @@ +/** + * 任务状态机:平台强制的规则只在这里写一遍。 + * + * `transition()` 是纯函数:只返回新任务与要做的副作用(持有记录、托管动作、事件、job), + * 由 service 在锁住任务行的同一个事务里执行。它不读库、不读时钟。 + * + * 不在这里判的规则(需要读其它表,由 service 在同一事务里保证): + * 重复报名(vk_task_entries UNIQUE)、转让请求是否 pending 且指向 actor、验收 checks 与清单的覆盖关系。 + */ + +import { TASK_STATUSES } from './task.js' + +/** 除发布(新建,见 `publishStatus`)外的全部动作。 */ +export const TASK_ACTIONS = Object.freeze([ + 'approve', + 'reject_approval', + 'claim', + 'enter', + 'cancel', + 'open_expire', + 'edit', + 'checkpoint', + 'checkpoint_miss', + 'release', + 'hold_timeout', + 'transfer_accept', + 'submit', + 'accept', + 'reject', + 'review_auto_accept', + 'contest_close', + 'select_winner', + 'dispute', + 'resolve', +]) + +/** 每个 Agent 同时持有的计件活上限。 */ +export const MAX_ACTIVE_HOLDS = 3 + +const HOUR_MS = 3600_000 + +/** + * @typedef {import('./task.js').Task} Task + * + * @typedef {{ + * task: Task, + * guild: { checkpoint_interval_sec: number, piece_review_window_sec: number, contest_review_window_sec: number }, + * publisher_owner_user_id: string, + * }} TaskState 任务行 + 所属公会的时间规则 + 发布方 Agent 的主人,由 service 在事务内读出。 + * + * @typedef {{ kind: 'agent', agent_id: string, owner_user_id: string, max_rarity: number, active_hold_count: number, is_entrant: boolean }} AgentActor + * @typedef {AgentActor | { kind: 'lead', user_id: string } | { kind: 'operator', user_id: string } | { kind: 'worker' }} Actor + * + * @typedef {( + * { type: 'approve' } | { type: 'reject_approval' } | { type: 'claim' } | { type: 'enter' } | { type: 'cancel' } | + * { type: 'open_expire' } | { type: 'edit', add_reward: number, deadline_at: Date|null } | { type: 'checkpoint' } | + * { type: 'checkpoint_miss' } | { type: 'release' } | { type: 'hold_timeout' } | { type: 'transfer_accept' } | + * { type: 'submit' } | { type: 'accept' } | { type: 'reject' } | { type: 'review_auto_accept', winner_agent_id: string|null } | + * { type: 'contest_close' } | { type: 'select_winner', winner_agent_id: string } | { type: 'dispute' } | + * { type: 'resolve', outcome: 'settled'|'refunded', winner_agent_id: string|null } + * )} Action + * + * @typedef {( + * { type: 'hold_start', agent_id: string } | + * { type: 'hold_end', reason: 'submitted'|'released'|'timeout'|'transferred' } | + * { type: 'hold_checkpoint' } | + * { type: 'entry_add', agent_id: string } | + * { type: 'escrow_topup', amount: number } | + * { type: 'escrow_refund' } | + * { type: 'escrow_settle', winner_agent_id: string } | + * { type: 'dispute_open', opened_by_agent_id: string } | + * { type: 'event', event_type: string, target_agent_id: string|null } | + * { type: 'job', kind: string, run_at: Date } + * )} Effect `event.target_agent_id` 为 null 表示发给整个公会。 + * + * @typedef {{ ok: true, task: Task, effects: Effect[] } | { ok: false, code: 'state_conflict'|'rule_violation'|'forbidden', hint: string }} TransitionResult + */ + +/** + * 发布时的初始状态:公会开启发布前审核且发布方是公司 Agent 时先进待审核。 + * @param {{ require_lead_approval: boolean, publisher_role: 'company'|'member' }} input + * @returns {'open'|'pending_approval'} + */ +export function publishStatus({ require_lead_approval, publisher_role }) { + switch (publisher_role) { + case 'company': + return require_lead_approval ? 'pending_approval' : 'open' + case 'member': + return 'open' + default: + throw new Error(`Unhandled publisher role: ${publisher_role}`) + } +} + +/** + * @param {TaskState} state + * @param {Action} action + * @param {Actor} actor + * @param {Date} now + * @returns {TransitionResult} + */ +export function transition(state, action, actor, now) { + const { task } = state + if (!TASK_ACTIONS.includes(action.type)) throw new Error(`Unhandled action: ${action.type}, id: ${task.id}`) + const handlers = TRANSITIONS[task.status] + if (handlers === undefined) throw new Error(`Unhandled status: ${task.status}, id: ${task.id}`) + const handler = handlers[action.type] + if (handler === undefined) return fail('state_conflict', `Task is ${task.status}; ${action.type} is not allowed. Re-fetch the task.`) + return handler(state, /** @type {any} */ (action), actor, now) +} + +/** @typedef {(state: TaskState, action: any, actor: Actor, now: Date) => TransitionResult} Handler */ + +/** @type {Record>>} */ +const TRANSITIONS = { + pending_approval: { + approve({ task }, _action, actor) { + if (actor.kind !== 'lead') return notAllowed('Only the guild lead can approve.') + return ok({ ...task, status: 'open' }, [event('task.published', null), ...openJobs(task)]) + }, + reject_approval({ task }, _action, actor) { + if (actor.kind !== 'lead') return notAllowed('Only the guild lead can reject.') + return ok({ ...task, status: 'cancelled' }, [{ type: 'escrow_refund' }]) + }, + }, + + open: { + claim(state, _action, actor, now) { + const { task } = state + if (task.template !== 'piecework') return fail('rule_violation', 'This task is a contest; use enter.') + const refused = takerRefusal(state, actor) + if (refused !== null) return refused + const taker = /** @type {AgentActor} */ (actor) + if (taker.active_hold_count >= MAX_ACTIVE_HOLDS) return fail('state_conflict', `You already hold ${MAX_ACTIVE_HOLDS} piecework tasks.`) + return ok(hold(state, taker.agent_id, now), [ + { type: 'hold_start', agent_id: taker.agent_id }, + event('task.claimed', task.publisher_agent_id), + ...checkpointJobs(state, now), + job('hold_timeout', task.deadline_at), + ]) + }, + enter(state, _action, actor) { + const { task } = state + if (task.template !== 'contest') return fail('rule_violation', 'This task is piecework; use claim.') + const refused = takerRefusal(state, actor) + if (refused !== null) return refused + return ok({ ...task, status: 'in_contest' }, [{ type: 'entry_add', agent_id: /** @type {AgentActor} */ (actor).agent_id }]) + }, + cancel({ task }, _action, actor) { + if (!isPublisher(task, actor)) return notAllowed('Only the publisher can cancel.') + return ok({ ...task, status: 'cancelled' }, [{ type: 'escrow_refund' }]) + }, + open_expire({ task }, _action, actor, now) { + if (actor.kind !== 'worker') return notAllowed('Only the platform can expire a task.') + if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') + return ok({ ...task, status: 'cancelled' }, [{ type: 'escrow_refund' }]) + }, + edit({ task }, action, actor) { + if (!isPublisher(task, actor)) return notAllowed('Only the publisher can edit.') + if (!Number.isSafeInteger(action.add_reward) || action.add_reward < 0) throw new Error(`edit.add_reward invalid: ${action.add_reward}, id: ${task.id}`) + if (action.add_reward === 0 && action.deadline_at === null) return fail('rule_violation', 'Nothing to change: add reward or extend the deadline.') + if (action.deadline_at !== null && action.deadline_at <= task.deadline_at) return fail('rule_violation', 'The deadline can only be extended.') + const next = { ...task, reward: task.reward + action.add_reward, deadline_at: action.deadline_at === null ? task.deadline_at : action.deadline_at } + return ok(next, [ + ...(action.add_reward === 0 ? [] : [/** @type {Effect} */ ({ type: 'escrow_topup', amount: action.add_reward })]), + ...(action.deadline_at === null ? [] : openJobs(next)), + ]) + }, + }, + + claimed: { + checkpoint(state, _action, actor, now) { + if (!isHolder(state.task, actor)) return notAllowed('Only the current holder can post a checkpoint.') + return ok({ ...state.task, next_checkpoint_due_at: checkpointDue(state, now), missed_checkpoints: 0 }, [ + { type: 'hold_checkpoint' }, + event('task.checkpoint', null), + ...checkpointJobs(state, now), + ]) + }, + checkpoint_miss(state, _action, actor, now) { + const { task } = state + if (actor.kind !== 'worker') return notAllowed('Only the platform can record a missed checkpoint.') + const due = /** @type {Date} */ (task.next_checkpoint_due_at) + if (now < due) return fail('state_conflict', 'Checkpoint is not overdue.') + if (task.missed_checkpoints >= 1) return ok(reopen(task), releaseEffects(task, 'timeout')) + // 第一次缺失只告警:从原到期时刻顺延一个间隔。 + return ok({ ...task, next_checkpoint_due_at: checkpointDue(state, due), missed_checkpoints: 1 }, checkpointJobs(state, due)) + }, + release({ task }, _action, actor) { + if (!isHolder(task, actor)) return notAllowed('Only the current holder can release.') + return ok(reopen(task), releaseEffects(task, 'released')) + }, + hold_timeout({ task }, _action, actor, now) { + if (actor.kind !== 'worker') return notAllowed('Only the platform can time out a hold.') + if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') + return ok(reopen(task), releaseEffects(task, 'timeout')) + }, + transfer_accept(state, _action, actor, now) { + const refused = takerRefusal(state, actor) + if (refused !== null) return refused + const taker = /** @type {AgentActor} */ (actor) + if (taker.agent_id === state.task.holder_agent_id) return fail('state_conflict', 'You already hold this task.') + if (taker.active_hold_count >= MAX_ACTIVE_HOLDS) return fail('state_conflict', `You already hold ${MAX_ACTIVE_HOLDS} piecework tasks.`) + return ok(hold(state, taker.agent_id, now), [ + { type: 'hold_end', reason: 'transferred' }, + { type: 'hold_start', agent_id: taker.agent_id }, + ...checkpointJobs(state, now), + ]) + }, + submit(state, _action, actor, now) { + if (!isHolder(state.task, actor)) return notAllowed('Only the current holder can submit.') + return ok(...submitted(state, now, [{ type: 'hold_end', reason: 'submitted' }])) + }, + }, + + in_contest: { + enter(state, _action, actor) { + const refused = takerRefusal(state, actor) + if (refused !== null) return refused + return ok(state.task, [{ type: 'entry_add', agent_id: /** @type {AgentActor} */ (actor).agent_id }]) + }, + submit({ task }, _action, actor) { + if (actor.kind !== 'agent' || !actor.is_entrant) return notAllowed('Enter the contest before submitting.') + // 提交封存到截止,不通知发布方。 + return ok(task, []) + }, + contest_close(state, _action, actor, now) { + const { task } = state + if (actor.kind !== 'worker') return notAllowed('Only the platform can close a contest.') + if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') + const reviewDue = new Date(now.getTime() + state.guild.contest_review_window_sec * 1000) + return ok({ ...task, status: 'reviewing', review_due_at: reviewDue }, [event('task.submitted', task.publisher_agent_id), ...reviewJobs(reviewDue)]) + }, + }, + + submitted: { + accept({ task }, _action, actor, now) { + if (!isPublisher(task, actor)) return notAllowed('Only the publisher can review.') + const holder = /** @type {string} */ (task.holder_agent_id) + return ok(settle(task, now), [{ type: 'escrow_settle', winner_agent_id: holder }, event('task.accepted', holder)]) + }, + review_auto_accept({ task }, _action, actor, now) { + if (actor.kind !== 'worker') return notAllowed('Only the platform can auto-accept.') + if (now < /** @type {Date} */ (task.review_due_at)) return fail('state_conflict', 'Review window has not expired.') + const holder = /** @type {string} */ (task.holder_agent_id) + return ok(settle(task, now), [ + { type: 'escrow_settle', winner_agent_id: holder }, + event('task.auto_accepted', task.publisher_agent_id), + event('task.auto_accepted', holder), + ]) + }, + reject({ task }, _action, actor, now) { + if (!isPublisher(task, actor) || actor.kind !== 'agent') return notAllowed('Only the publisher can review.') + const holder = /** @type {string} */ (task.holder_agent_id) + // 第 3 次驳回直接进仲裁。 + if (task.rework_count >= 2) return ok({ ...task, status: 'disputed' }, disputeEffects(actor.agent_id, now)) + return ok({ ...task, status: 'rework', rework_count: task.rework_count + 1, review_due_at: null }, [event('task.rejected', holder)]) + }, + dispute: openDispute, + }, + + reviewing: { + select_winner({ task }, action, actor, now) { + if (!isPublisher(task, actor)) return notAllowed('Only the publisher can select a winner.') + return ok(settle(task, now), [{ type: 'escrow_settle', winner_agent_id: action.winner_agent_id }, event('task.accepted', action.winner_agent_id)]) + }, + review_auto_accept({ task }, action, actor, now) { + if (actor.kind !== 'worker') return notAllowed('Only the platform can auto-accept.') + if (now < /** @type {Date} */ (task.review_due_at)) return fail('state_conflict', 'Review window has not expired.') + // 没有任何提交全部通过清单:退款。 + if (action.winner_agent_id === null) return ok({ ...task, status: 'refunded' }, [{ type: 'escrow_refund' }, event('task.auto_accepted', task.publisher_agent_id)]) + return ok(settle(task, now), [ + { type: 'escrow_settle', winner_agent_id: action.winner_agent_id }, + event('task.auto_accepted', task.publisher_agent_id), + event('task.auto_accepted', action.winner_agent_id), + ]) + }, + dispute: openDispute, + }, + + rework: { + submit(state, _action, actor, now) { + if (!isHolder(state.task, actor)) return notAllowed('Only the current holder can submit.') + return ok(...submitted(state, now, [])) + }, + dispute: openDispute, + }, + + disputed: { + resolve({ task }, action, actor, now) { + if (actor.kind !== 'operator') return notAllowed('Only platform operators can resolve a dispute.') + const notify = [event('dispute.resolved', task.publisher_agent_id), ...(task.holder_agent_id === null ? [] : [event('dispute.resolved', task.holder_agent_id)])] + switch (action.outcome) { + case 'settled': + if (action.winner_agent_id === null) throw new Error(`resolve.winner_agent_id missing for settled, id: ${task.id}`) + return ok(settle(task, now), [{ type: 'escrow_settle', winner_agent_id: action.winner_agent_id }, ...notify]) + case 'refunded': + return ok({ ...task, status: 'refunded' }, [{ type: 'escrow_refund' }, ...notify]) + default: + throw new Error(`Unhandled outcome: ${action.outcome}, id: ${task.id}`) + } + }, + }, + + settled: {}, + cancelled: {}, + refunded: {}, +} + +if (Object.keys(TRANSITIONS).length !== TASK_STATUSES.length) throw new Error('TRANSITIONS must cover every task status') + +/** @type {Handler} */ +function openDispute({ task }, _action, actor, now) { + if (actor.kind !== 'agent') return notAllowed('Only the publisher or a taker can open a dispute.') + const isParty = actor.agent_id === task.publisher_agent_id || actor.agent_id === task.holder_agent_id || actor.is_entrant + if (!isParty) return notAllowed('Only the publisher or a taker can open a dispute.') + return ok({ ...task, status: 'disputed' }, disputeEffects(actor.agent_id, now)) +} + +/** + * 接单方通用校验:必须是 Agent、不能接自己或同主人 Agent 的任务、稀有度不超过等级上限。 + * @param {TaskState} state + * @param {Actor} actor + * @returns {TransitionResult|null} + */ +function takerRefusal({ task, publisher_owner_user_id }, actor) { + if (actor.kind !== 'agent') return notAllowed('Only agents can take tasks.') + if (actor.agent_id === task.publisher_agent_id || actor.owner_user_id === publisher_owner_user_id) { + return fail('state_conflict', 'You cannot take a task published by you or by another agent of your owner.') + } + if (task.rarity > actor.max_rarity) return fail('rule_violation', `Task rarity ${task.rarity} is above your level cap ${actor.max_rarity}.`) + return null +} + +/** + * @param {Task} task + * @param {Actor} actor + */ +function isPublisher(task, actor) { + return actor.kind === 'agent' && actor.agent_id === task.publisher_agent_id +} + +/** + * @param {Task} task + * @param {Actor} actor + */ +function isHolder(task, actor) { + return actor.kind === 'agent' && actor.agent_id === task.holder_agent_id +} + +/** + * @param {TaskState} state + * @param {Date} from + */ +function checkpointDue(state, from) { + return new Date(from.getTime() + state.guild.checkpoint_interval_sec * 1000) +} + +/** + * @param {TaskState} state + * @param {string} agentId + * @param {Date} now + * @returns {Task} + */ +function hold(state, agentId, now) { + return { ...state.task, status: 'claimed', holder_agent_id: agentId, next_checkpoint_due_at: checkpointDue(state, now), missed_checkpoints: 0 } +} + +/** + * @param {Task} task + * @returns {Task} + */ +function reopen(task) { + return { ...task, status: 'open', holder_agent_id: null, next_checkpoint_due_at: null, missed_checkpoints: 0 } +} + +/** + * @param {Task} task + * @param {Date} now + * @returns {Task} + */ +function settle(task, now) { + return { ...task, status: 'settled', settled_at: now } +} + +/** + * @param {TaskState} state + * @param {Date} now + * @param {Effect[]} before + * @returns {[Task, Effect[]]} + */ +function submitted(state, now, before) { + const { task } = state + const reviewDue = new Date(now.getTime() + state.guild.piece_review_window_sec * 1000) + return [ + { ...task, status: 'submitted', next_checkpoint_due_at: null, missed_checkpoints: 0, review_due_at: reviewDue }, + [...before, event('task.submitted', task.publisher_agent_id), ...reviewJobs(reviewDue)], + ] +} + +/** + * @param {Task} task + * @param {'released'|'timeout'} reason + * @returns {Effect[]} + */ +function releaseEffects(task, reason) { + return [{ type: 'hold_end', reason }, event('task.released', /** @type {string} */ (task.holder_agent_id)), event('task.released', task.publisher_agent_id)] +} + +/** + * @param {string} openedBy + * @param {Date} now + * @returns {Effect[]} + */ +function disputeEffects(openedBy, now) { + return [{ type: 'dispute_open', opened_by_agent_id: openedBy }, job('dispute_arbitrate', now)] +} + +/** + * 任务上板后的到期 job:过期无人接;擂台赛另加到点关闭。 + * @param {Task} task + * @returns {Effect[]} + */ +function openJobs(task) { + switch (task.template) { + case 'piecework': + return [job('open_expire', task.deadline_at)] + case 'contest': + return [job('open_expire', task.deadline_at), job('contest_close', task.deadline_at)] + default: + throw new Error(`Unhandled template: ${task.template}, id: ${task.id}`) + } +} + +/** + * @param {TaskState} state + * @param {Date} from + * @returns {Effect[]} + */ +function checkpointJobs(state, from) { + const due = checkpointDue(state, from) + return [job('checkpoint_warn', new Date(due.getTime() - HOUR_MS)), job('checkpoint_miss', due)] +} + +/** + * @param {Date} reviewDue + * @returns {Effect[]} + */ +function reviewJobs(reviewDue) { + return [job('review_warn', new Date(reviewDue.getTime() - 2 * HOUR_MS)), job('review_auto_accept', reviewDue)] +} + +/** + * @param {string} eventType + * @param {string|null} targetAgentId + * @returns {Effect} + */ +function event(eventType, targetAgentId) { + return { type: 'event', event_type: eventType, target_agent_id: targetAgentId } +} + +/** + * @param {string} kind + * @param {Date} runAt + * @returns {Effect} + */ +function job(kind, runAt) { + return { type: 'job', kind, run_at: runAt } +} + +/** + * @param {Task} task + * @param {Effect[]} effects + * @returns {TransitionResult} + */ +function ok(task, effects) { + return { ok: true, task, effects } +} + +/** + * @param {'state_conflict'|'rule_violation'|'forbidden'} code + * @param {string} hint + * @returns {TransitionResult} + */ +function fail(code, hint) { + return { ok: false, code, hint } +} + +/** + * @param {string} hint + * @returns {TransitionResult} + */ +function notAllowed(hint) { + return fail('forbidden', hint) +} diff --git a/packages/voko-exchange/test/transition.test.js b/packages/voko-exchange/test/transition.test.js new file mode 100644 index 0000000..174ede5 --- /dev/null +++ b/packages/voko-exchange/test/transition.test.js @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest' +import { taskFromRow, TASK_STATUSES } from '../src/task.js' +import { publishStatus, TASK_ACTIONS, transition } from '../src/transition.js' + +const NOW = new Date('2026-10-12T12:00:00.000Z') +const PAST = new Date('2026-10-12T06:00:00.000Z') +const FUTURE = new Date('2026-10-13T12:00:00.000Z') +const guild = { checkpoint_interval_sec: 21600, piece_review_window_sec: 86400, contest_review_window_sec: 259200 } + +const publisher = agent('ag_pub', 'u_pub') +const holder = agent('ag_holder', 'u_holder') +const taker = agent('ag_taker', 'u_taker') +const entrant = { ...agent('ag_entrant', 'u_entrant'), is_entrant: true } +const lead = { kind: 'lead', user_id: 'u_lead' } +const operator = { kind: 'operator', user_id: 'u_ops' } +const worker = { kind: 'worker' } + +function agent(agent_id, owner_user_id) { + return { kind: 'agent', agent_id, owner_user_id, max_rarity: 10, active_hold_count: 0, is_entrant: false } +} + +/** 每个状态一条符合生产 schema 的任务行(到期类时间都已过,便于 worker 动作成立)。 */ +function stateFor(status, template = TEMPLATE_OF[status] ?? 'piecework', overrides = {}) { + const held = ['claimed', 'submitted', 'rework'].includes(status) || (status === 'disputed' && template === 'piecework') + const task = taskFromRow({ + id: 't_1', + guild_id: 'g_1', + publisher_agent_id: 'ag_pub', + template, + title: 'Translate onboarding email', + spec: 'Translate to Japanese', + reward: 100, + rarity: 2, + deadline_at: PAST, + status, + client_request_key: 'req_1', + holder_agent_id: held ? 'ag_holder' : null, + next_checkpoint_due_at: status === 'claimed' ? PAST : null, + missed_checkpoints: 0, + rework_count: status === 'rework' ? 1 : 0, + review_due_at: ['submitted', 'reviewing'].includes(status) ? PAST : null, + settled_at: status === 'settled' ? PAST : null, + created_at: PAST, + updated_at: null, + ...overrides, + }) + return { task, guild, publisher_owner_user_id: 'u_pub' } +} + +const TEMPLATE_OF = { in_contest: 'contest', reviewing: 'contest' } + +/** 每个动作的合法发起者与参数。 */ +const ACTOR_OF = { + approve: lead, + reject_approval: lead, + claim: taker, + enter: taker, + cancel: publisher, + open_expire: worker, + edit: publisher, + checkpoint: holder, + checkpoint_miss: worker, + release: holder, + hold_timeout: worker, + transfer_accept: taker, + submit: holder, + accept: publisher, + reject: publisher, + review_auto_accept: worker, + contest_close: worker, + select_winner: publisher, + dispute: publisher, + resolve: operator, +} +const PARAMS_OF = { + edit: { add_reward: 50, deadline_at: FUTURE }, + review_auto_accept: { winner_agent_id: 'ag_entrant' }, + select_winner: { winner_agent_id: 'ag_entrant' }, + resolve: { outcome: 'settled', winner_agent_id: 'ag_holder' }, +} + +/** SDD 状态转移表:status → action → 期望的新状态。表里没有的组合一律 state_conflict。 */ +const EXPECTED = { + pending_approval: { approve: 'open', reject_approval: 'cancelled' }, + open: { claim: 'claimed', enter: 'in_contest', cancel: 'cancelled', open_expire: 'cancelled', edit: 'open' }, + claimed: { checkpoint: 'claimed', checkpoint_miss: 'claimed', release: 'open', hold_timeout: 'open', transfer_accept: 'claimed', submit: 'submitted' }, + in_contest: { enter: 'in_contest', submit: 'in_contest', contest_close: 'reviewing' }, + submitted: { accept: 'settled', reject: 'rework', review_auto_accept: 'settled', dispute: 'disputed' }, + reviewing: { select_winner: 'settled', review_auto_accept: 'settled', dispute: 'disputed' }, + rework: { submit: 'submitted', dispute: 'disputed' }, + disputed: { resolve: 'settled' }, + settled: {}, + cancelled: {}, + refunded: {}, +} + +function run(state, type, actor = ACTOR_OF[type], params = PARAMS_OF[type]) { + return transition(state, { type, ...params }, actor, NOW) +} + +const effectTypes = (result) => result.effects.map((effect) => effect.type === 'event' ? effect.event_type : effect.type === 'job' ? `job:${effect.kind}` : effect.type) + +describe('SM-01 transition():11 个状态 × 全部动作', () => { + it('期望表覆盖全部状态', () => { + expect(Object.keys(EXPECTED)).toEqual([...TASK_STATUSES]) + }) + + for (const status of TASK_STATUSES) { + for (const type of TASK_ACTIONS) { + const expected = EXPECTED[status][type] + it(`${status} × ${type} → ${expected ?? 'state_conflict'}`, () => { + const actor = status === 'in_contest' && type === 'submit' ? entrant : ACTOR_OF[type] + // open 的 enter 只对擂台赛成立;计件活 enter 的规则错误在下面单独断言。 + const template = status === 'open' && type === 'enter' ? 'contest' : undefined + const result = run(stateFor(status, template), type, actor) + if (expected === undefined) { + expect(result).toMatchObject({ ok: false, code: 'state_conflict' }) + return + } + expect(result.ok).toBe(true) + expect(result.task.status).toBe(expected) + // 新任务必须仍满足构造点的不变量。 + expect(() => taskFromRow(result.task)).not.toThrow() + }) + } + } + + it('open 擂台赛:enter → in_contest,claim 是规则错误', () => { + const contest = stateFor('open', 'contest') + expect(run(contest, 'enter')).toMatchObject({ ok: true, task: { status: 'in_contest' }, effects: [{ type: 'entry_add', agent_id: 'ag_taker' }] }) + expect(run(contest, 'claim')).toMatchObject({ ok: false, code: 'rule_violation' }) + expect(run(stateFor('open'), 'enter')).toMatchObject({ ok: false, code: 'rule_violation' }) + }) + + it('错误的发起者一律 forbidden', () => { + for (const status of TASK_STATUSES) { + for (const type of Object.keys(EXPECTED[status])) { + if (type === 'claim' || type === 'enter' || type === 'transfer_accept') continue + const stranger = ACTOR_OF[type].kind === 'agent' ? worker : agent('ag_stranger', 'u_stranger') + expect(run(stateFor(status), type, stranger), `${status} × ${type}`).toMatchObject({ ok: false, code: 'forbidden' }) + } + } + }) +}) + +describe('SM-01 副作用与平台规则', () => { + it('认领:开始持有、通知发布方、排检查点与超时 job', () => { + const result = run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'claim') + expect(effectTypes(result)).toEqual(['hold_start', 'task.claimed', 'job:checkpoint_warn', 'job:checkpoint_miss', 'job:hold_timeout']) + expect(result.task.holder_agent_id).toBe('ag_taker') + expect(result.task.next_checkpoint_due_at).toEqual(new Date('2026-10-12T18:00:00.000Z')) + expect(result.effects[2].run_at).toEqual(new Date('2026-10-12T17:00:00.000Z')) + }) + + it('自接自单、同主人、并发上限 → state_conflict;稀有度超等级 → rule_violation', () => { + const open = stateFor('open') + expect(run(open, 'claim', publisher)).toMatchObject({ ok: false, code: 'state_conflict' }) + expect(run(open, 'claim', agent('ag_sibling', 'u_pub'))).toMatchObject({ ok: false, code: 'state_conflict' }) + expect(run(open, 'claim', { ...taker, active_hold_count: 3 })).toMatchObject({ ok: false, code: 'state_conflict' }) + expect(run(open, 'claim', { ...taker, active_hold_count: 2 }).ok).toBe(true) + expect(run(open, 'claim', { ...taker, max_rarity: 1 })).toMatchObject({ ok: false, code: 'rule_violation' }) + expect(run(stateFor('open', 'contest'), 'enter', agent('ag_sibling', 'u_pub'))).toMatchObject({ ok: false, code: 'state_conflict' }) + }) + + it('检查点缺一次只告警并顺延,缺两次释放回 open', () => { + const first = run(stateFor('claimed'), 'checkpoint_miss') + expect(first.task).toMatchObject({ status: 'claimed', missed_checkpoints: 1, next_checkpoint_due_at: new Date('2026-10-12T12:00:00.000Z') }) + const second = run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint_miss') + expect(second.task).toMatchObject({ status: 'open', holder_agent_id: null, missed_checkpoints: 0 }) + expect(effectTypes(second)).toEqual(['hold_end', 'task.released', 'task.released']) + expect(second.effects[0].reason).toBe('timeout') + expect(run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint').task.missed_checkpoints).toBe(0) + }) + + it('未到期的 worker 动作是过时 job,返回 state_conflict', () => { + expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'open_expire')).toMatchObject({ ok: false, code: 'state_conflict' }) + expect(run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'hold_timeout')).toMatchObject({ ok: false, code: 'state_conflict' }) + expect(run(stateFor('claimed', 'piecework', { next_checkpoint_due_at: FUTURE }), 'checkpoint_miss')).toMatchObject({ ok: false, code: 'state_conflict' }) + expect(run(stateFor('in_contest', 'contest', { deadline_at: FUTURE }), 'contest_close')).toMatchObject({ ok: false, code: 'state_conflict' }) + expect(run(stateFor('submitted', 'piecework', { review_due_at: FUTURE }), 'review_auto_accept')).toMatchObject({ ok: false, code: 'state_conflict' }) + }) + + it('提交开启验收窗口:计件活 24h,擂台赛关闭后 72h', () => { + const submitted = run(stateFor('claimed'), 'submit') + expect(submitted.task.review_due_at).toEqual(new Date('2026-10-13T12:00:00.000Z')) + expect(effectTypes(submitted)).toEqual(['hold_end', 'task.submitted', 'job:review_warn', 'job:review_auto_accept']) + expect(run(stateFor('in_contest'), 'contest_close').task.review_due_at).toEqual(new Date('2026-10-15T12:00:00.000Z')) + }) + + it('驳回两次进返工,第 3 次进仲裁', () => { + expect(run(stateFor('submitted'), 'reject').task).toMatchObject({ status: 'rework', rework_count: 1, review_due_at: null }) + expect(run(stateFor('submitted', 'piecework', { rework_count: 1 }), 'reject').task).toMatchObject({ status: 'rework', rework_count: 2 }) + const third = run(stateFor('submitted', 'piecework', { rework_count: 2 }), 'reject') + expect(third.task).toMatchObject({ status: 'disputed', rework_count: 2 }) + expect(effectTypes(third)).toEqual(['dispute_open', 'job:dispute_arbitrate']) + }) + + it('结算与退款的托管动作', () => { + expect(run(stateFor('submitted'), 'accept').effects[0]).toEqual({ type: 'escrow_settle', winner_agent_id: 'ag_holder' }) + expect(run(stateFor('reviewing'), 'select_winner').effects[0]).toEqual({ type: 'escrow_settle', winner_agent_id: 'ag_entrant' }) + const noWinner = run(stateFor('reviewing'), 'review_auto_accept', worker, { winner_agent_id: null }) + expect(noWinner.task.status).toBe('refunded') + expect(noWinner.effects[0]).toEqual({ type: 'escrow_refund' }) + expect(run(stateFor('disputed'), 'resolve', operator, { outcome: 'refunded', winner_agent_id: null }).task.status).toBe('refunded') + for (const type of ['cancel', 'open_expire']) expect(run(stateFor('open'), type).effects).toEqual([{ type: 'escrow_refund' }]) + }) + + it('修改只能加赏金或延长截止', () => { + const edited = run(stateFor('open'), 'edit') + expect(edited.task).toMatchObject({ reward: 150, deadline_at: FUTURE }) + expect(effectTypes(edited)).toEqual(['escrow_topup', 'job:open_expire']) + expect(run(stateFor('open'), 'edit', publisher, { add_reward: 0, deadline_at: null })).toMatchObject({ ok: false, code: 'rule_violation' }) + expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'edit', publisher, { add_reward: 0, deadline_at: PAST })).toMatchObject({ ok: false, code: 'rule_violation' }) + }) + + it('争议只能由发布方或接单方发起', () => { + expect(run(stateFor('submitted'), 'dispute', holder).task.status).toBe('disputed') + expect(run(stateFor('reviewing'), 'dispute', entrant).task.status).toBe('disputed') + expect(run(stateFor('submitted'), 'dispute', taker)).toMatchObject({ ok: false, code: 'forbidden' }) + }) + + it('发布时的初始状态', () => { + expect(publishStatus({ require_lead_approval: true, publisher_role: 'company' })).toBe('pending_approval') + expect(publishStatus({ require_lead_approval: false, publisher_role: 'company' })).toBe('open') + expect(publishStatus({ require_lead_approval: true, publisher_role: 'member' })).toBe('open') + }) + + it('未知动作与非法行直接 throw', () => { + expect(() => transition(stateFor('open'), { type: 'teleport' }, worker, NOW)).toThrow(/Unhandled action: teleport, id: t_1/) + expect(() => stateFor('archived')).toThrow(/Unhandled status: archived, id: t_1/) + expect(() => stateFor('claimed', 'piecework', { holder_agent_id: null })).toThrow(/holder_agent_id missing/) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b51f63a..3e88489 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,7 +294,11 @@ importers: packages/voko-clearing: {} - packages/voko-exchange: {} + packages/voko-exchange: + devDependencies: + vitest: + specifier: ^2.1.4 + version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0) packages: