Commit ff13178430c560f188de736768c0f74024a298b1
1 parent
9a6089c7
feat(exchange): 任务构造点 taskFromRow 与状态机 transition(),SM-01 穷举测试
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
6 changed files
with
917 additions
and
2 deletions
packages/voko-exchange/package.json
packages/voko-exchange/src/index.js
| 1 | 1 | /** |
| 2 | 2 | * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。 |
| 3 | 3 | * |
| 4 | - * 当前只有表结构;状态机与 service 在 W1–W3 加入。 | |
| 4 | + * 当前有表结构、任务构造点与状态机纯函数;service 在 W2–W3 加入。 | |
| 5 | 5 | * |
| 6 | 6 | * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | +export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' | |
| 10 | +export { transition, publishStatus, TASK_ACTIONS, MAX_ACTIVE_HOLDS } from './transition.js' | |
| 9 | 11 | export { default as exchangeDbSchema } from './db/tables/index.cjs' | ... | ... |
packages/voko-exchange/src/task.js
0 → 100644
| 1 | +/** 任务的全部状态;消费方 switch 必须穷举。与 DB CHECK `ck_vk_tasks_status` 一致。 */ | |
| 2 | +export const TASK_STATUSES = Object.freeze([ | |
| 3 | + 'pending_approval', | |
| 4 | + 'open', | |
| 5 | + 'claimed', | |
| 6 | + 'in_contest', | |
| 7 | + 'submitted', | |
| 8 | + 'reviewing', | |
| 9 | + 'rework', | |
| 10 | + 'disputed', | |
| 11 | + 'settled', | |
| 12 | + 'cancelled', | |
| 13 | + 'refunded', | |
| 14 | +]) | |
| 15 | + | |
| 16 | +export const TASK_TEMPLATES = Object.freeze(['piecework', 'contest']) | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * @typedef {'pending_approval'|'open'|'claimed'|'in_contest'|'submitted'|'reviewing'|'rework'|'disputed'|'settled'|'cancelled'|'refunded'} TaskStatus | |
| 20 | + * | |
| 21 | + * @typedef {{ | |
| 22 | + * id: string, | |
| 23 | + * guild_id: string, | |
| 24 | + * publisher_agent_id: string, | |
| 25 | + * template: 'piecework'|'contest', | |
| 26 | + * title: string, | |
| 27 | + * spec: string, | |
| 28 | + * reward: number, | |
| 29 | + * rarity: number, | |
| 30 | + * deadline_at: Date, | |
| 31 | + * status: TaskStatus, | |
| 32 | + * client_request_key: string, | |
| 33 | + * holder_agent_id: string|null, | |
| 34 | + * next_checkpoint_due_at: Date|null, | |
| 35 | + * missed_checkpoints: number, | |
| 36 | + * rework_count: number, | |
| 37 | + * review_due_at: Date|null, | |
| 38 | + * settled_at: Date|null, | |
| 39 | + * created_at: Date, | |
| 40 | + * updated_at: Date|null, | |
| 41 | + * }} Task | |
| 42 | + */ | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * DB 行 → Task 的唯一构造点。按状态保证必有字段,非法即 throw;下游读取零判空。 | |
| 46 | + * @param {Record<string, any>} row | |
| 47 | + * @returns {Task} | |
| 48 | + */ | |
| 49 | +export function taskFromRow(row) { | |
| 50 | + const id = requiredString(row.id, 'id', row.id) | |
| 51 | + if (!TASK_TEMPLATES.includes(row.template)) throw new Error(`vk_tasks.template invalid: ${row.template}, id: ${id}`) | |
| 52 | + const holder = row.holder_agent_id === null ? null : requiredString(row.holder_agent_id, 'holder_agent_id', id) | |
| 53 | + const nextCheckpointDue = nullableDate(row.next_checkpoint_due_at, 'next_checkpoint_due_at', id) | |
| 54 | + const reviewDue = nullableDate(row.review_due_at, 'review_due_at', id) | |
| 55 | + const settledAt = nullableDate(row.settled_at, 'settled_at', id) | |
| 56 | + | |
| 57 | + switch (row.status) { | |
| 58 | + case 'claimed': | |
| 59 | + requirePiecework(row, id) | |
| 60 | + if (holder === null) throw new Error(`vk_tasks.holder_agent_id missing for claimed, id: ${id}`) | |
| 61 | + if (nextCheckpointDue === null) throw new Error(`vk_tasks.next_checkpoint_due_at missing for claimed, id: ${id}`) | |
| 62 | + break | |
| 63 | + case 'submitted': | |
| 64 | + requirePiecework(row, id) | |
| 65 | + if (holder === null) throw new Error(`vk_tasks.holder_agent_id missing for submitted, id: ${id}`) | |
| 66 | + if (reviewDue === null) throw new Error(`vk_tasks.review_due_at missing for submitted, id: ${id}`) | |
| 67 | + break | |
| 68 | + case 'rework': | |
| 69 | + requirePiecework(row, id) | |
| 70 | + if (holder === null) throw new Error(`vk_tasks.holder_agent_id missing for rework, id: ${id}`) | |
| 71 | + break | |
| 72 | + case 'in_contest': | |
| 73 | + requireContest(row, id) | |
| 74 | + break | |
| 75 | + case 'reviewing': | |
| 76 | + requireContest(row, id) | |
| 77 | + if (reviewDue === null) throw new Error(`vk_tasks.review_due_at missing for reviewing, id: ${id}`) | |
| 78 | + break | |
| 79 | + case 'settled': | |
| 80 | + if (settledAt === null) throw new Error(`vk_tasks.settled_at missing for settled, id: ${id}`) | |
| 81 | + break | |
| 82 | + case 'pending_approval': | |
| 83 | + case 'open': | |
| 84 | + case 'disputed': | |
| 85 | + case 'cancelled': | |
| 86 | + case 'refunded': | |
| 87 | + break | |
| 88 | + default: | |
| 89 | + throw new Error(`Unhandled status: ${row.status}, id: ${id}`) | |
| 90 | + } | |
| 91 | + if (row.template === 'contest' && holder !== null) throw new Error(`vk_tasks contest has holder, id: ${id}`) | |
| 92 | + | |
| 93 | + return { | |
| 94 | + id, | |
| 95 | + guild_id: requiredString(row.guild_id, 'guild_id', id), | |
| 96 | + publisher_agent_id: requiredString(row.publisher_agent_id, 'publisher_agent_id', id), | |
| 97 | + template: row.template, | |
| 98 | + title: requiredString(row.title, 'title', id), | |
| 99 | + spec: requiredString(row.spec, 'spec', id), | |
| 100 | + reward: integerInRange(row.reward, 1, Number.MAX_SAFE_INTEGER, 'reward', id), | |
| 101 | + rarity: integerInRange(row.rarity, 1, 10, 'rarity', id), | |
| 102 | + deadline_at: requiredDate(row.deadline_at, 'deadline_at', id), | |
| 103 | + status: row.status, | |
| 104 | + client_request_key: requiredString(row.client_request_key, 'client_request_key', id), | |
| 105 | + holder_agent_id: holder, | |
| 106 | + next_checkpoint_due_at: nextCheckpointDue, | |
| 107 | + missed_checkpoints: integerInRange(row.missed_checkpoints, 0, 1, 'missed_checkpoints', id), | |
| 108 | + rework_count: integerInRange(row.rework_count, 0, 2, 'rework_count', id), | |
| 109 | + review_due_at: reviewDue, | |
| 110 | + settled_at: settledAt, | |
| 111 | + created_at: requiredDate(row.created_at, 'created_at', id), | |
| 112 | + updated_at: nullableDate(row.updated_at, 'updated_at', id), | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +/** | |
| 117 | + * @param {Record<string, any>} row | |
| 118 | + * @param {string} id | |
| 119 | + */ | |
| 120 | +function requirePiecework(row, id) { | |
| 121 | + if (row.template !== 'piecework') throw new Error(`vk_tasks status ${row.status} requires piecework, id: ${id}`) | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** | |
| 125 | + * @param {Record<string, any>} row | |
| 126 | + * @param {string} id | |
| 127 | + */ | |
| 128 | +function requireContest(row, id) { | |
| 129 | + if (row.template !== 'contest') throw new Error(`vk_tasks status ${row.status} requires contest, id: ${id}`) | |
| 130 | +} | |
| 131 | + | |
| 132 | +/** | |
| 133 | + * @param {unknown} value | |
| 134 | + * @param {string} field | |
| 135 | + * @param {unknown} id | |
| 136 | + * @returns {string} | |
| 137 | + */ | |
| 138 | +function requiredString(value, field, id) { | |
| 139 | + if (typeof value === 'string' && value.length > 0) return value | |
| 140 | + throw new Error(`vk_tasks.${field} invalid, id: ${String(id)}`) | |
| 141 | +} | |
| 142 | + | |
| 143 | +/** | |
| 144 | + * @param {unknown} value | |
| 145 | + * @param {string} field | |
| 146 | + * @param {string} id | |
| 147 | + * @returns {Date} | |
| 148 | + */ | |
| 149 | +function requiredDate(value, field, id) { | |
| 150 | + if (value instanceof Date) return value | |
| 151 | + throw new Error(`vk_tasks.${field} invalid, id: ${id}`) | |
| 152 | +} | |
| 153 | + | |
| 154 | +/** | |
| 155 | + * @param {unknown} value | |
| 156 | + * @param {string} field | |
| 157 | + * @param {string} id | |
| 158 | + * @returns {Date|null} | |
| 159 | + */ | |
| 160 | +function nullableDate(value, field, id) { | |
| 161 | + return value === null ? null : requiredDate(value, field, id) | |
| 162 | +} | |
| 163 | + | |
| 164 | +/** | |
| 165 | + * @param {unknown} value | |
| 166 | + * @param {number} min | |
| 167 | + * @param {number} max | |
| 168 | + * @param {string} field | |
| 169 | + * @param {string} id | |
| 170 | + * @returns {number} | |
| 171 | + */ | |
| 172 | +function integerInRange(value, min, max, field, id) { | |
| 173 | + if (Number.isSafeInteger(value) && /** @type {number} */ (value) >= min && /** @type {number} */ (value) <= max) return /** @type {number} */ (value) | |
| 174 | + throw new Error(`vk_tasks.${field} invalid: ${String(value)}, id: ${id}`) | |
| 175 | +} | ... | ... |
packages/voko-exchange/src/transition.js
0 → 100644
| 1 | +/** | |
| 2 | + * 任务状态机:平台强制的规则只在这里写一遍。 | |
| 3 | + * | |
| 4 | + * `transition()` 是纯函数:只返回新任务与要做的副作用(持有记录、托管动作、事件、job), | |
| 5 | + * 由 service 在锁住任务行的同一个事务里执行。它不读库、不读时钟。 | |
| 6 | + * | |
| 7 | + * 不在这里判的规则(需要读其它表,由 service 在同一事务里保证): | |
| 8 | + * 重复报名(vk_task_entries UNIQUE)、转让请求是否 pending 且指向 actor、验收 checks 与清单的覆盖关系。 | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import { TASK_STATUSES } from './task.js' | |
| 12 | + | |
| 13 | +/** 除发布(新建,见 `publishStatus`)外的全部动作。 */ | |
| 14 | +export const TASK_ACTIONS = Object.freeze([ | |
| 15 | + 'approve', | |
| 16 | + 'reject_approval', | |
| 17 | + 'claim', | |
| 18 | + 'enter', | |
| 19 | + 'cancel', | |
| 20 | + 'open_expire', | |
| 21 | + 'edit', | |
| 22 | + 'checkpoint', | |
| 23 | + 'checkpoint_miss', | |
| 24 | + 'release', | |
| 25 | + 'hold_timeout', | |
| 26 | + 'transfer_accept', | |
| 27 | + 'submit', | |
| 28 | + 'accept', | |
| 29 | + 'reject', | |
| 30 | + 'review_auto_accept', | |
| 31 | + 'contest_close', | |
| 32 | + 'select_winner', | |
| 33 | + 'dispute', | |
| 34 | + 'resolve', | |
| 35 | +]) | |
| 36 | + | |
| 37 | +/** 每个 Agent 同时持有的计件活上限。 */ | |
| 38 | +export const MAX_ACTIVE_HOLDS = 3 | |
| 39 | + | |
| 40 | +const HOUR_MS = 3600_000 | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * @typedef {import('./task.js').Task} Task | |
| 44 | + * | |
| 45 | + * @typedef {{ | |
| 46 | + * task: Task, | |
| 47 | + * guild: { checkpoint_interval_sec: number, piece_review_window_sec: number, contest_review_window_sec: number }, | |
| 48 | + * publisher_owner_user_id: string, | |
| 49 | + * }} TaskState 任务行 + 所属公会的时间规则 + 发布方 Agent 的主人,由 service 在事务内读出。 | |
| 50 | + * | |
| 51 | + * @typedef {{ kind: 'agent', agent_id: string, owner_user_id: string, max_rarity: number, active_hold_count: number, is_entrant: boolean }} AgentActor | |
| 52 | + * @typedef {AgentActor | { kind: 'lead', user_id: string } | { kind: 'operator', user_id: string } | { kind: 'worker' }} Actor | |
| 53 | + * | |
| 54 | + * @typedef {( | |
| 55 | + * { type: 'approve' } | { type: 'reject_approval' } | { type: 'claim' } | { type: 'enter' } | { type: 'cancel' } | | |
| 56 | + * { type: 'open_expire' } | { type: 'edit', add_reward: number, deadline_at: Date|null } | { type: 'checkpoint' } | | |
| 57 | + * { type: 'checkpoint_miss' } | { type: 'release' } | { type: 'hold_timeout' } | { type: 'transfer_accept' } | | |
| 58 | + * { type: 'submit' } | { type: 'accept' } | { type: 'reject' } | { type: 'review_auto_accept', winner_agent_id: string|null } | | |
| 59 | + * { type: 'contest_close' } | { type: 'select_winner', winner_agent_id: string } | { type: 'dispute' } | | |
| 60 | + * { type: 'resolve', outcome: 'settled'|'refunded', winner_agent_id: string|null } | |
| 61 | + * )} Action | |
| 62 | + * | |
| 63 | + * @typedef {( | |
| 64 | + * { type: 'hold_start', agent_id: string } | | |
| 65 | + * { type: 'hold_end', reason: 'submitted'|'released'|'timeout'|'transferred' } | | |
| 66 | + * { type: 'hold_checkpoint' } | | |
| 67 | + * { type: 'entry_add', agent_id: string } | | |
| 68 | + * { type: 'escrow_topup', amount: number } | | |
| 69 | + * { type: 'escrow_refund' } | | |
| 70 | + * { type: 'escrow_settle', winner_agent_id: string } | | |
| 71 | + * { type: 'dispute_open', opened_by_agent_id: string } | | |
| 72 | + * { type: 'event', event_type: string, target_agent_id: string|null } | | |
| 73 | + * { type: 'job', kind: string, run_at: Date } | |
| 74 | + * )} Effect `event.target_agent_id` 为 null 表示发给整个公会。 | |
| 75 | + * | |
| 76 | + * @typedef {{ ok: true, task: Task, effects: Effect[] } | { ok: false, code: 'state_conflict'|'rule_violation'|'forbidden', hint: string }} TransitionResult | |
| 77 | + */ | |
| 78 | + | |
| 79 | +/** | |
| 80 | + * 发布时的初始状态:公会开启发布前审核且发布方是公司 Agent 时先进待审核。 | |
| 81 | + * @param {{ require_lead_approval: boolean, publisher_role: 'company'|'member' }} input | |
| 82 | + * @returns {'open'|'pending_approval'} | |
| 83 | + */ | |
| 84 | +export function publishStatus({ require_lead_approval, publisher_role }) { | |
| 85 | + switch (publisher_role) { | |
| 86 | + case 'company': | |
| 87 | + return require_lead_approval ? 'pending_approval' : 'open' | |
| 88 | + case 'member': | |
| 89 | + return 'open' | |
| 90 | + default: | |
| 91 | + throw new Error(`Unhandled publisher role: ${publisher_role}`) | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +/** | |
| 96 | + * @param {TaskState} state | |
| 97 | + * @param {Action} action | |
| 98 | + * @param {Actor} actor | |
| 99 | + * @param {Date} now | |
| 100 | + * @returns {TransitionResult} | |
| 101 | + */ | |
| 102 | +export function transition(state, action, actor, now) { | |
| 103 | + const { task } = state | |
| 104 | + if (!TASK_ACTIONS.includes(action.type)) throw new Error(`Unhandled action: ${action.type}, id: ${task.id}`) | |
| 105 | + const handlers = TRANSITIONS[task.status] | |
| 106 | + if (handlers === undefined) throw new Error(`Unhandled status: ${task.status}, id: ${task.id}`) | |
| 107 | + const handler = handlers[action.type] | |
| 108 | + if (handler === undefined) return fail('state_conflict', `Task is ${task.status}; ${action.type} is not allowed. Re-fetch the task.`) | |
| 109 | + return handler(state, /** @type {any} */ (action), actor, now) | |
| 110 | +} | |
| 111 | + | |
| 112 | +/** @typedef {(state: TaskState, action: any, actor: Actor, now: Date) => TransitionResult} Handler */ | |
| 113 | + | |
| 114 | +/** @type {Record<import('./task.js').TaskStatus, Partial<Record<Action['type'], Handler>>>} */ | |
| 115 | +const TRANSITIONS = { | |
| 116 | + pending_approval: { | |
| 117 | + approve({ task }, _action, actor) { | |
| 118 | + if (actor.kind !== 'lead') return notAllowed('Only the guild lead can approve.') | |
| 119 | + return ok({ ...task, status: 'open' }, [event('task.published', null), ...openJobs(task)]) | |
| 120 | + }, | |
| 121 | + reject_approval({ task }, _action, actor) { | |
| 122 | + if (actor.kind !== 'lead') return notAllowed('Only the guild lead can reject.') | |
| 123 | + return ok({ ...task, status: 'cancelled' }, [{ type: 'escrow_refund' }]) | |
| 124 | + }, | |
| 125 | + }, | |
| 126 | + | |
| 127 | + open: { | |
| 128 | + claim(state, _action, actor, now) { | |
| 129 | + const { task } = state | |
| 130 | + if (task.template !== 'piecework') return fail('rule_violation', 'This task is a contest; use enter.') | |
| 131 | + const refused = takerRefusal(state, actor) | |
| 132 | + if (refused !== null) return refused | |
| 133 | + const taker = /** @type {AgentActor} */ (actor) | |
| 134 | + if (taker.active_hold_count >= MAX_ACTIVE_HOLDS) return fail('state_conflict', `You already hold ${MAX_ACTIVE_HOLDS} piecework tasks.`) | |
| 135 | + return ok(hold(state, taker.agent_id, now), [ | |
| 136 | + { type: 'hold_start', agent_id: taker.agent_id }, | |
| 137 | + event('task.claimed', task.publisher_agent_id), | |
| 138 | + ...checkpointJobs(state, now), | |
| 139 | + job('hold_timeout', task.deadline_at), | |
| 140 | + ]) | |
| 141 | + }, | |
| 142 | + enter(state, _action, actor) { | |
| 143 | + const { task } = state | |
| 144 | + if (task.template !== 'contest') return fail('rule_violation', 'This task is piecework; use claim.') | |
| 145 | + const refused = takerRefusal(state, actor) | |
| 146 | + if (refused !== null) return refused | |
| 147 | + return ok({ ...task, status: 'in_contest' }, [{ type: 'entry_add', agent_id: /** @type {AgentActor} */ (actor).agent_id }]) | |
| 148 | + }, | |
| 149 | + cancel({ task }, _action, actor) { | |
| 150 | + if (!isPublisher(task, actor)) return notAllowed('Only the publisher can cancel.') | |
| 151 | + return ok({ ...task, status: 'cancelled' }, [{ type: 'escrow_refund' }]) | |
| 152 | + }, | |
| 153 | + open_expire({ task }, _action, actor, now) { | |
| 154 | + if (actor.kind !== 'worker') return notAllowed('Only the platform can expire a task.') | |
| 155 | + if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') | |
| 156 | + return ok({ ...task, status: 'cancelled' }, [{ type: 'escrow_refund' }]) | |
| 157 | + }, | |
| 158 | + edit({ task }, action, actor) { | |
| 159 | + if (!isPublisher(task, actor)) return notAllowed('Only the publisher can edit.') | |
| 160 | + if (!Number.isSafeInteger(action.add_reward) || action.add_reward < 0) throw new Error(`edit.add_reward invalid: ${action.add_reward}, id: ${task.id}`) | |
| 161 | + if (action.add_reward === 0 && action.deadline_at === null) return fail('rule_violation', 'Nothing to change: add reward or extend the deadline.') | |
| 162 | + if (action.deadline_at !== null && action.deadline_at <= task.deadline_at) return fail('rule_violation', 'The deadline can only be extended.') | |
| 163 | + const next = { ...task, reward: task.reward + action.add_reward, deadline_at: action.deadline_at === null ? task.deadline_at : action.deadline_at } | |
| 164 | + return ok(next, [ | |
| 165 | + ...(action.add_reward === 0 ? [] : [/** @type {Effect} */ ({ type: 'escrow_topup', amount: action.add_reward })]), | |
| 166 | + ...(action.deadline_at === null ? [] : openJobs(next)), | |
| 167 | + ]) | |
| 168 | + }, | |
| 169 | + }, | |
| 170 | + | |
| 171 | + claimed: { | |
| 172 | + checkpoint(state, _action, actor, now) { | |
| 173 | + if (!isHolder(state.task, actor)) return notAllowed('Only the current holder can post a checkpoint.') | |
| 174 | + return ok({ ...state.task, next_checkpoint_due_at: checkpointDue(state, now), missed_checkpoints: 0 }, [ | |
| 175 | + { type: 'hold_checkpoint' }, | |
| 176 | + event('task.checkpoint', null), | |
| 177 | + ...checkpointJobs(state, now), | |
| 178 | + ]) | |
| 179 | + }, | |
| 180 | + checkpoint_miss(state, _action, actor, now) { | |
| 181 | + const { task } = state | |
| 182 | + if (actor.kind !== 'worker') return notAllowed('Only the platform can record a missed checkpoint.') | |
| 183 | + const due = /** @type {Date} */ (task.next_checkpoint_due_at) | |
| 184 | + if (now < due) return fail('state_conflict', 'Checkpoint is not overdue.') | |
| 185 | + if (task.missed_checkpoints >= 1) return ok(reopen(task), releaseEffects(task, 'timeout')) | |
| 186 | + // 第一次缺失只告警:从原到期时刻顺延一个间隔。 | |
| 187 | + return ok({ ...task, next_checkpoint_due_at: checkpointDue(state, due), missed_checkpoints: 1 }, checkpointJobs(state, due)) | |
| 188 | + }, | |
| 189 | + release({ task }, _action, actor) { | |
| 190 | + if (!isHolder(task, actor)) return notAllowed('Only the current holder can release.') | |
| 191 | + return ok(reopen(task), releaseEffects(task, 'released')) | |
| 192 | + }, | |
| 193 | + hold_timeout({ task }, _action, actor, now) { | |
| 194 | + if (actor.kind !== 'worker') return notAllowed('Only the platform can time out a hold.') | |
| 195 | + if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') | |
| 196 | + return ok(reopen(task), releaseEffects(task, 'timeout')) | |
| 197 | + }, | |
| 198 | + transfer_accept(state, _action, actor, now) { | |
| 199 | + const refused = takerRefusal(state, actor) | |
| 200 | + if (refused !== null) return refused | |
| 201 | + const taker = /** @type {AgentActor} */ (actor) | |
| 202 | + if (taker.agent_id === state.task.holder_agent_id) return fail('state_conflict', 'You already hold this task.') | |
| 203 | + if (taker.active_hold_count >= MAX_ACTIVE_HOLDS) return fail('state_conflict', `You already hold ${MAX_ACTIVE_HOLDS} piecework tasks.`) | |
| 204 | + return ok(hold(state, taker.agent_id, now), [ | |
| 205 | + { type: 'hold_end', reason: 'transferred' }, | |
| 206 | + { type: 'hold_start', agent_id: taker.agent_id }, | |
| 207 | + ...checkpointJobs(state, now), | |
| 208 | + ]) | |
| 209 | + }, | |
| 210 | + submit(state, _action, actor, now) { | |
| 211 | + if (!isHolder(state.task, actor)) return notAllowed('Only the current holder can submit.') | |
| 212 | + return ok(...submitted(state, now, [{ type: 'hold_end', reason: 'submitted' }])) | |
| 213 | + }, | |
| 214 | + }, | |
| 215 | + | |
| 216 | + in_contest: { | |
| 217 | + enter(state, _action, actor) { | |
| 218 | + const refused = takerRefusal(state, actor) | |
| 219 | + if (refused !== null) return refused | |
| 220 | + return ok(state.task, [{ type: 'entry_add', agent_id: /** @type {AgentActor} */ (actor).agent_id }]) | |
| 221 | + }, | |
| 222 | + submit({ task }, _action, actor) { | |
| 223 | + if (actor.kind !== 'agent' || !actor.is_entrant) return notAllowed('Enter the contest before submitting.') | |
| 224 | + // 提交封存到截止,不通知发布方。 | |
| 225 | + return ok(task, []) | |
| 226 | + }, | |
| 227 | + contest_close(state, _action, actor, now) { | |
| 228 | + const { task } = state | |
| 229 | + if (actor.kind !== 'worker') return notAllowed('Only the platform can close a contest.') | |
| 230 | + if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') | |
| 231 | + const reviewDue = new Date(now.getTime() + state.guild.contest_review_window_sec * 1000) | |
| 232 | + return ok({ ...task, status: 'reviewing', review_due_at: reviewDue }, [event('task.submitted', task.publisher_agent_id), ...reviewJobs(reviewDue)]) | |
| 233 | + }, | |
| 234 | + }, | |
| 235 | + | |
| 236 | + submitted: { | |
| 237 | + accept({ task }, _action, actor, now) { | |
| 238 | + if (!isPublisher(task, actor)) return notAllowed('Only the publisher can review.') | |
| 239 | + const holder = /** @type {string} */ (task.holder_agent_id) | |
| 240 | + return ok(settle(task, now), [{ type: 'escrow_settle', winner_agent_id: holder }, event('task.accepted', holder)]) | |
| 241 | + }, | |
| 242 | + review_auto_accept({ task }, _action, actor, now) { | |
| 243 | + if (actor.kind !== 'worker') return notAllowed('Only the platform can auto-accept.') | |
| 244 | + if (now < /** @type {Date} */ (task.review_due_at)) return fail('state_conflict', 'Review window has not expired.') | |
| 245 | + const holder = /** @type {string} */ (task.holder_agent_id) | |
| 246 | + return ok(settle(task, now), [ | |
| 247 | + { type: 'escrow_settle', winner_agent_id: holder }, | |
| 248 | + event('task.auto_accepted', task.publisher_agent_id), | |
| 249 | + event('task.auto_accepted', holder), | |
| 250 | + ]) | |
| 251 | + }, | |
| 252 | + reject({ task }, _action, actor, now) { | |
| 253 | + if (!isPublisher(task, actor) || actor.kind !== 'agent') return notAllowed('Only the publisher can review.') | |
| 254 | + const holder = /** @type {string} */ (task.holder_agent_id) | |
| 255 | + // 第 3 次驳回直接进仲裁。 | |
| 256 | + if (task.rework_count >= 2) return ok({ ...task, status: 'disputed' }, disputeEffects(actor.agent_id, now)) | |
| 257 | + return ok({ ...task, status: 'rework', rework_count: task.rework_count + 1, review_due_at: null }, [event('task.rejected', holder)]) | |
| 258 | + }, | |
| 259 | + dispute: openDispute, | |
| 260 | + }, | |
| 261 | + | |
| 262 | + reviewing: { | |
| 263 | + select_winner({ task }, action, actor, now) { | |
| 264 | + if (!isPublisher(task, actor)) return notAllowed('Only the publisher can select a winner.') | |
| 265 | + return ok(settle(task, now), [{ type: 'escrow_settle', winner_agent_id: action.winner_agent_id }, event('task.accepted', action.winner_agent_id)]) | |
| 266 | + }, | |
| 267 | + review_auto_accept({ task }, action, actor, now) { | |
| 268 | + if (actor.kind !== 'worker') return notAllowed('Only the platform can auto-accept.') | |
| 269 | + if (now < /** @type {Date} */ (task.review_due_at)) return fail('state_conflict', 'Review window has not expired.') | |
| 270 | + // 没有任何提交全部通过清单:退款。 | |
| 271 | + if (action.winner_agent_id === null) return ok({ ...task, status: 'refunded' }, [{ type: 'escrow_refund' }, event('task.auto_accepted', task.publisher_agent_id)]) | |
| 272 | + return ok(settle(task, now), [ | |
| 273 | + { type: 'escrow_settle', winner_agent_id: action.winner_agent_id }, | |
| 274 | + event('task.auto_accepted', task.publisher_agent_id), | |
| 275 | + event('task.auto_accepted', action.winner_agent_id), | |
| 276 | + ]) | |
| 277 | + }, | |
| 278 | + dispute: openDispute, | |
| 279 | + }, | |
| 280 | + | |
| 281 | + rework: { | |
| 282 | + submit(state, _action, actor, now) { | |
| 283 | + if (!isHolder(state.task, actor)) return notAllowed('Only the current holder can submit.') | |
| 284 | + return ok(...submitted(state, now, [])) | |
| 285 | + }, | |
| 286 | + dispute: openDispute, | |
| 287 | + }, | |
| 288 | + | |
| 289 | + disputed: { | |
| 290 | + resolve({ task }, action, actor, now) { | |
| 291 | + if (actor.kind !== 'operator') return notAllowed('Only platform operators can resolve a dispute.') | |
| 292 | + const notify = [event('dispute.resolved', task.publisher_agent_id), ...(task.holder_agent_id === null ? [] : [event('dispute.resolved', task.holder_agent_id)])] | |
| 293 | + switch (action.outcome) { | |
| 294 | + case 'settled': | |
| 295 | + if (action.winner_agent_id === null) throw new Error(`resolve.winner_agent_id missing for settled, id: ${task.id}`) | |
| 296 | + return ok(settle(task, now), [{ type: 'escrow_settle', winner_agent_id: action.winner_agent_id }, ...notify]) | |
| 297 | + case 'refunded': | |
| 298 | + return ok({ ...task, status: 'refunded' }, [{ type: 'escrow_refund' }, ...notify]) | |
| 299 | + default: | |
| 300 | + throw new Error(`Unhandled outcome: ${action.outcome}, id: ${task.id}`) | |
| 301 | + } | |
| 302 | + }, | |
| 303 | + }, | |
| 304 | + | |
| 305 | + settled: {}, | |
| 306 | + cancelled: {}, | |
| 307 | + refunded: {}, | |
| 308 | +} | |
| 309 | + | |
| 310 | +if (Object.keys(TRANSITIONS).length !== TASK_STATUSES.length) throw new Error('TRANSITIONS must cover every task status') | |
| 311 | + | |
| 312 | +/** @type {Handler} */ | |
| 313 | +function openDispute({ task }, _action, actor, now) { | |
| 314 | + if (actor.kind !== 'agent') return notAllowed('Only the publisher or a taker can open a dispute.') | |
| 315 | + const isParty = actor.agent_id === task.publisher_agent_id || actor.agent_id === task.holder_agent_id || actor.is_entrant | |
| 316 | + if (!isParty) return notAllowed('Only the publisher or a taker can open a dispute.') | |
| 317 | + return ok({ ...task, status: 'disputed' }, disputeEffects(actor.agent_id, now)) | |
| 318 | +} | |
| 319 | + | |
| 320 | +/** | |
| 321 | + * 接单方通用校验:必须是 Agent、不能接自己或同主人 Agent 的任务、稀有度不超过等级上限。 | |
| 322 | + * @param {TaskState} state | |
| 323 | + * @param {Actor} actor | |
| 324 | + * @returns {TransitionResult|null} | |
| 325 | + */ | |
| 326 | +function takerRefusal({ task, publisher_owner_user_id }, actor) { | |
| 327 | + if (actor.kind !== 'agent') return notAllowed('Only agents can take tasks.') | |
| 328 | + if (actor.agent_id === task.publisher_agent_id || actor.owner_user_id === publisher_owner_user_id) { | |
| 329 | + return fail('state_conflict', 'You cannot take a task published by you or by another agent of your owner.') | |
| 330 | + } | |
| 331 | + if (task.rarity > actor.max_rarity) return fail('rule_violation', `Task rarity ${task.rarity} is above your level cap ${actor.max_rarity}.`) | |
| 332 | + return null | |
| 333 | +} | |
| 334 | + | |
| 335 | +/** | |
| 336 | + * @param {Task} task | |
| 337 | + * @param {Actor} actor | |
| 338 | + */ | |
| 339 | +function isPublisher(task, actor) { | |
| 340 | + return actor.kind === 'agent' && actor.agent_id === task.publisher_agent_id | |
| 341 | +} | |
| 342 | + | |
| 343 | +/** | |
| 344 | + * @param {Task} task | |
| 345 | + * @param {Actor} actor | |
| 346 | + */ | |
| 347 | +function isHolder(task, actor) { | |
| 348 | + return actor.kind === 'agent' && actor.agent_id === task.holder_agent_id | |
| 349 | +} | |
| 350 | + | |
| 351 | +/** | |
| 352 | + * @param {TaskState} state | |
| 353 | + * @param {Date} from | |
| 354 | + */ | |
| 355 | +function checkpointDue(state, from) { | |
| 356 | + return new Date(from.getTime() + state.guild.checkpoint_interval_sec * 1000) | |
| 357 | +} | |
| 358 | + | |
| 359 | +/** | |
| 360 | + * @param {TaskState} state | |
| 361 | + * @param {string} agentId | |
| 362 | + * @param {Date} now | |
| 363 | + * @returns {Task} | |
| 364 | + */ | |
| 365 | +function hold(state, agentId, now) { | |
| 366 | + return { ...state.task, status: 'claimed', holder_agent_id: agentId, next_checkpoint_due_at: checkpointDue(state, now), missed_checkpoints: 0 } | |
| 367 | +} | |
| 368 | + | |
| 369 | +/** | |
| 370 | + * @param {Task} task | |
| 371 | + * @returns {Task} | |
| 372 | + */ | |
| 373 | +function reopen(task) { | |
| 374 | + return { ...task, status: 'open', holder_agent_id: null, next_checkpoint_due_at: null, missed_checkpoints: 0 } | |
| 375 | +} | |
| 376 | + | |
| 377 | +/** | |
| 378 | + * @param {Task} task | |
| 379 | + * @param {Date} now | |
| 380 | + * @returns {Task} | |
| 381 | + */ | |
| 382 | +function settle(task, now) { | |
| 383 | + return { ...task, status: 'settled', settled_at: now } | |
| 384 | +} | |
| 385 | + | |
| 386 | +/** | |
| 387 | + * @param {TaskState} state | |
| 388 | + * @param {Date} now | |
| 389 | + * @param {Effect[]} before | |
| 390 | + * @returns {[Task, Effect[]]} | |
| 391 | + */ | |
| 392 | +function submitted(state, now, before) { | |
| 393 | + const { task } = state | |
| 394 | + const reviewDue = new Date(now.getTime() + state.guild.piece_review_window_sec * 1000) | |
| 395 | + return [ | |
| 396 | + { ...task, status: 'submitted', next_checkpoint_due_at: null, missed_checkpoints: 0, review_due_at: reviewDue }, | |
| 397 | + [...before, event('task.submitted', task.publisher_agent_id), ...reviewJobs(reviewDue)], | |
| 398 | + ] | |
| 399 | +} | |
| 400 | + | |
| 401 | +/** | |
| 402 | + * @param {Task} task | |
| 403 | + * @param {'released'|'timeout'} reason | |
| 404 | + * @returns {Effect[]} | |
| 405 | + */ | |
| 406 | +function releaseEffects(task, reason) { | |
| 407 | + return [{ type: 'hold_end', reason }, event('task.released', /** @type {string} */ (task.holder_agent_id)), event('task.released', task.publisher_agent_id)] | |
| 408 | +} | |
| 409 | + | |
| 410 | +/** | |
| 411 | + * @param {string} openedBy | |
| 412 | + * @param {Date} now | |
| 413 | + * @returns {Effect[]} | |
| 414 | + */ | |
| 415 | +function disputeEffects(openedBy, now) { | |
| 416 | + return [{ type: 'dispute_open', opened_by_agent_id: openedBy }, job('dispute_arbitrate', now)] | |
| 417 | +} | |
| 418 | + | |
| 419 | +/** | |
| 420 | + * 任务上板后的到期 job:过期无人接;擂台赛另加到点关闭。 | |
| 421 | + * @param {Task} task | |
| 422 | + * @returns {Effect[]} | |
| 423 | + */ | |
| 424 | +function openJobs(task) { | |
| 425 | + switch (task.template) { | |
| 426 | + case 'piecework': | |
| 427 | + return [job('open_expire', task.deadline_at)] | |
| 428 | + case 'contest': | |
| 429 | + return [job('open_expire', task.deadline_at), job('contest_close', task.deadline_at)] | |
| 430 | + default: | |
| 431 | + throw new Error(`Unhandled template: ${task.template}, id: ${task.id}`) | |
| 432 | + } | |
| 433 | +} | |
| 434 | + | |
| 435 | +/** | |
| 436 | + * @param {TaskState} state | |
| 437 | + * @param {Date} from | |
| 438 | + * @returns {Effect[]} | |
| 439 | + */ | |
| 440 | +function checkpointJobs(state, from) { | |
| 441 | + const due = checkpointDue(state, from) | |
| 442 | + return [job('checkpoint_warn', new Date(due.getTime() - HOUR_MS)), job('checkpoint_miss', due)] | |
| 443 | +} | |
| 444 | + | |
| 445 | +/** | |
| 446 | + * @param {Date} reviewDue | |
| 447 | + * @returns {Effect[]} | |
| 448 | + */ | |
| 449 | +function reviewJobs(reviewDue) { | |
| 450 | + return [job('review_warn', new Date(reviewDue.getTime() - 2 * HOUR_MS)), job('review_auto_accept', reviewDue)] | |
| 451 | +} | |
| 452 | + | |
| 453 | +/** | |
| 454 | + * @param {string} eventType | |
| 455 | + * @param {string|null} targetAgentId | |
| 456 | + * @returns {Effect} | |
| 457 | + */ | |
| 458 | +function event(eventType, targetAgentId) { | |
| 459 | + return { type: 'event', event_type: eventType, target_agent_id: targetAgentId } | |
| 460 | +} | |
| 461 | + | |
| 462 | +/** | |
| 463 | + * @param {string} kind | |
| 464 | + * @param {Date} runAt | |
| 465 | + * @returns {Effect} | |
| 466 | + */ | |
| 467 | +function job(kind, runAt) { | |
| 468 | + return { type: 'job', kind, run_at: runAt } | |
| 469 | +} | |
| 470 | + | |
| 471 | +/** | |
| 472 | + * @param {Task} task | |
| 473 | + * @param {Effect[]} effects | |
| 474 | + * @returns {TransitionResult} | |
| 475 | + */ | |
| 476 | +function ok(task, effects) { | |
| 477 | + return { ok: true, task, effects } | |
| 478 | +} | |
| 479 | + | |
| 480 | +/** | |
| 481 | + * @param {'state_conflict'|'rule_violation'|'forbidden'} code | |
| 482 | + * @param {string} hint | |
| 483 | + * @returns {TransitionResult} | |
| 484 | + */ | |
| 485 | +function fail(code, hint) { | |
| 486 | + return { ok: false, code, hint } | |
| 487 | +} | |
| 488 | + | |
| 489 | +/** | |
| 490 | + * @param {string} hint | |
| 491 | + * @returns {TransitionResult} | |
| 492 | + */ | |
| 493 | +function notAllowed(hint) { | |
| 494 | + return fail('forbidden', hint) | |
| 495 | +} | ... | ... |
packages/voko-exchange/test/transition.test.js
0 → 100644
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { taskFromRow, TASK_STATUSES } from '../src/task.js' | |
| 3 | +import { publishStatus, TASK_ACTIONS, transition } from '../src/transition.js' | |
| 4 | + | |
| 5 | +const NOW = new Date('2026-10-12T12:00:00.000Z') | |
| 6 | +const PAST = new Date('2026-10-12T06:00:00.000Z') | |
| 7 | +const FUTURE = new Date('2026-10-13T12:00:00.000Z') | |
| 8 | +const guild = { checkpoint_interval_sec: 21600, piece_review_window_sec: 86400, contest_review_window_sec: 259200 } | |
| 9 | + | |
| 10 | +const publisher = agent('ag_pub', 'u_pub') | |
| 11 | +const holder = agent('ag_holder', 'u_holder') | |
| 12 | +const taker = agent('ag_taker', 'u_taker') | |
| 13 | +const entrant = { ...agent('ag_entrant', 'u_entrant'), is_entrant: true } | |
| 14 | +const lead = { kind: 'lead', user_id: 'u_lead' } | |
| 15 | +const operator = { kind: 'operator', user_id: 'u_ops' } | |
| 16 | +const worker = { kind: 'worker' } | |
| 17 | + | |
| 18 | +function agent(agent_id, owner_user_id) { | |
| 19 | + return { kind: 'agent', agent_id, owner_user_id, max_rarity: 10, active_hold_count: 0, is_entrant: false } | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** 每个状态一条符合生产 schema 的任务行(到期类时间都已过,便于 worker 动作成立)。 */ | |
| 23 | +function stateFor(status, template = TEMPLATE_OF[status] ?? 'piecework', overrides = {}) { | |
| 24 | + const held = ['claimed', 'submitted', 'rework'].includes(status) || (status === 'disputed' && template === 'piecework') | |
| 25 | + const task = taskFromRow({ | |
| 26 | + id: 't_1', | |
| 27 | + guild_id: 'g_1', | |
| 28 | + publisher_agent_id: 'ag_pub', | |
| 29 | + template, | |
| 30 | + title: 'Translate onboarding email', | |
| 31 | + spec: 'Translate to Japanese', | |
| 32 | + reward: 100, | |
| 33 | + rarity: 2, | |
| 34 | + deadline_at: PAST, | |
| 35 | + status, | |
| 36 | + client_request_key: 'req_1', | |
| 37 | + holder_agent_id: held ? 'ag_holder' : null, | |
| 38 | + next_checkpoint_due_at: status === 'claimed' ? PAST : null, | |
| 39 | + missed_checkpoints: 0, | |
| 40 | + rework_count: status === 'rework' ? 1 : 0, | |
| 41 | + review_due_at: ['submitted', 'reviewing'].includes(status) ? PAST : null, | |
| 42 | + settled_at: status === 'settled' ? PAST : null, | |
| 43 | + created_at: PAST, | |
| 44 | + updated_at: null, | |
| 45 | + ...overrides, | |
| 46 | + }) | |
| 47 | + return { task, guild, publisher_owner_user_id: 'u_pub' } | |
| 48 | +} | |
| 49 | + | |
| 50 | +const TEMPLATE_OF = { in_contest: 'contest', reviewing: 'contest' } | |
| 51 | + | |
| 52 | +/** 每个动作的合法发起者与参数。 */ | |
| 53 | +const ACTOR_OF = { | |
| 54 | + approve: lead, | |
| 55 | + reject_approval: lead, | |
| 56 | + claim: taker, | |
| 57 | + enter: taker, | |
| 58 | + cancel: publisher, | |
| 59 | + open_expire: worker, | |
| 60 | + edit: publisher, | |
| 61 | + checkpoint: holder, | |
| 62 | + checkpoint_miss: worker, | |
| 63 | + release: holder, | |
| 64 | + hold_timeout: worker, | |
| 65 | + transfer_accept: taker, | |
| 66 | + submit: holder, | |
| 67 | + accept: publisher, | |
| 68 | + reject: publisher, | |
| 69 | + review_auto_accept: worker, | |
| 70 | + contest_close: worker, | |
| 71 | + select_winner: publisher, | |
| 72 | + dispute: publisher, | |
| 73 | + resolve: operator, | |
| 74 | +} | |
| 75 | +const PARAMS_OF = { | |
| 76 | + edit: { add_reward: 50, deadline_at: FUTURE }, | |
| 77 | + review_auto_accept: { winner_agent_id: 'ag_entrant' }, | |
| 78 | + select_winner: { winner_agent_id: 'ag_entrant' }, | |
| 79 | + resolve: { outcome: 'settled', winner_agent_id: 'ag_holder' }, | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** SDD 状态转移表:status → action → 期望的新状态。表里没有的组合一律 state_conflict。 */ | |
| 83 | +const EXPECTED = { | |
| 84 | + pending_approval: { approve: 'open', reject_approval: 'cancelled' }, | |
| 85 | + open: { claim: 'claimed', enter: 'in_contest', cancel: 'cancelled', open_expire: 'cancelled', edit: 'open' }, | |
| 86 | + claimed: { checkpoint: 'claimed', checkpoint_miss: 'claimed', release: 'open', hold_timeout: 'open', transfer_accept: 'claimed', submit: 'submitted' }, | |
| 87 | + in_contest: { enter: 'in_contest', submit: 'in_contest', contest_close: 'reviewing' }, | |
| 88 | + submitted: { accept: 'settled', reject: 'rework', review_auto_accept: 'settled', dispute: 'disputed' }, | |
| 89 | + reviewing: { select_winner: 'settled', review_auto_accept: 'settled', dispute: 'disputed' }, | |
| 90 | + rework: { submit: 'submitted', dispute: 'disputed' }, | |
| 91 | + disputed: { resolve: 'settled' }, | |
| 92 | + settled: {}, | |
| 93 | + cancelled: {}, | |
| 94 | + refunded: {}, | |
| 95 | +} | |
| 96 | + | |
| 97 | +function run(state, type, actor = ACTOR_OF[type], params = PARAMS_OF[type]) { | |
| 98 | + return transition(state, { type, ...params }, actor, NOW) | |
| 99 | +} | |
| 100 | + | |
| 101 | +const effectTypes = (result) => result.effects.map((effect) => effect.type === 'event' ? effect.event_type : effect.type === 'job' ? `job:${effect.kind}` : effect.type) | |
| 102 | + | |
| 103 | +describe('SM-01 transition():11 个状态 × 全部动作', () => { | |
| 104 | + it('期望表覆盖全部状态', () => { | |
| 105 | + expect(Object.keys(EXPECTED)).toEqual([...TASK_STATUSES]) | |
| 106 | + }) | |
| 107 | + | |
| 108 | + for (const status of TASK_STATUSES) { | |
| 109 | + for (const type of TASK_ACTIONS) { | |
| 110 | + const expected = EXPECTED[status][type] | |
| 111 | + it(`${status} × ${type} → ${expected ?? 'state_conflict'}`, () => { | |
| 112 | + const actor = status === 'in_contest' && type === 'submit' ? entrant : ACTOR_OF[type] | |
| 113 | + // open 的 enter 只对擂台赛成立;计件活 enter 的规则错误在下面单独断言。 | |
| 114 | + const template = status === 'open' && type === 'enter' ? 'contest' : undefined | |
| 115 | + const result = run(stateFor(status, template), type, actor) | |
| 116 | + if (expected === undefined) { | |
| 117 | + expect(result).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 118 | + return | |
| 119 | + } | |
| 120 | + expect(result.ok).toBe(true) | |
| 121 | + expect(result.task.status).toBe(expected) | |
| 122 | + // 新任务必须仍满足构造点的不变量。 | |
| 123 | + expect(() => taskFromRow(result.task)).not.toThrow() | |
| 124 | + }) | |
| 125 | + } | |
| 126 | + } | |
| 127 | + | |
| 128 | + it('open 擂台赛:enter → in_contest,claim 是规则错误', () => { | |
| 129 | + const contest = stateFor('open', 'contest') | |
| 130 | + expect(run(contest, 'enter')).toMatchObject({ ok: true, task: { status: 'in_contest' }, effects: [{ type: 'entry_add', agent_id: 'ag_taker' }] }) | |
| 131 | + expect(run(contest, 'claim')).toMatchObject({ ok: false, code: 'rule_violation' }) | |
| 132 | + expect(run(stateFor('open'), 'enter')).toMatchObject({ ok: false, code: 'rule_violation' }) | |
| 133 | + }) | |
| 134 | + | |
| 135 | + it('错误的发起者一律 forbidden', () => { | |
| 136 | + for (const status of TASK_STATUSES) { | |
| 137 | + for (const type of Object.keys(EXPECTED[status])) { | |
| 138 | + if (type === 'claim' || type === 'enter' || type === 'transfer_accept') continue | |
| 139 | + const stranger = ACTOR_OF[type].kind === 'agent' ? worker : agent('ag_stranger', 'u_stranger') | |
| 140 | + expect(run(stateFor(status), type, stranger), `${status} × ${type}`).toMatchObject({ ok: false, code: 'forbidden' }) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + }) | |
| 144 | +}) | |
| 145 | + | |
| 146 | +describe('SM-01 副作用与平台规则', () => { | |
| 147 | + it('认领:开始持有、通知发布方、排检查点与超时 job', () => { | |
| 148 | + const result = run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'claim') | |
| 149 | + expect(effectTypes(result)).toEqual(['hold_start', 'task.claimed', 'job:checkpoint_warn', 'job:checkpoint_miss', 'job:hold_timeout']) | |
| 150 | + expect(result.task.holder_agent_id).toBe('ag_taker') | |
| 151 | + expect(result.task.next_checkpoint_due_at).toEqual(new Date('2026-10-12T18:00:00.000Z')) | |
| 152 | + expect(result.effects[2].run_at).toEqual(new Date('2026-10-12T17:00:00.000Z')) | |
| 153 | + }) | |
| 154 | + | |
| 155 | + it('自接自单、同主人、并发上限 → state_conflict;稀有度超等级 → rule_violation', () => { | |
| 156 | + const open = stateFor('open') | |
| 157 | + expect(run(open, 'claim', publisher)).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 158 | + expect(run(open, 'claim', agent('ag_sibling', 'u_pub'))).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 159 | + expect(run(open, 'claim', { ...taker, active_hold_count: 3 })).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 160 | + expect(run(open, 'claim', { ...taker, active_hold_count: 2 }).ok).toBe(true) | |
| 161 | + expect(run(open, 'claim', { ...taker, max_rarity: 1 })).toMatchObject({ ok: false, code: 'rule_violation' }) | |
| 162 | + expect(run(stateFor('open', 'contest'), 'enter', agent('ag_sibling', 'u_pub'))).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 163 | + }) | |
| 164 | + | |
| 165 | + it('检查点缺一次只告警并顺延,缺两次释放回 open', () => { | |
| 166 | + const first = run(stateFor('claimed'), 'checkpoint_miss') | |
| 167 | + expect(first.task).toMatchObject({ status: 'claimed', missed_checkpoints: 1, next_checkpoint_due_at: new Date('2026-10-12T12:00:00.000Z') }) | |
| 168 | + const second = run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint_miss') | |
| 169 | + expect(second.task).toMatchObject({ status: 'open', holder_agent_id: null, missed_checkpoints: 0 }) | |
| 170 | + expect(effectTypes(second)).toEqual(['hold_end', 'task.released', 'task.released']) | |
| 171 | + expect(second.effects[0].reason).toBe('timeout') | |
| 172 | + expect(run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint').task.missed_checkpoints).toBe(0) | |
| 173 | + }) | |
| 174 | + | |
| 175 | + it('未到期的 worker 动作是过时 job,返回 state_conflict', () => { | |
| 176 | + expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'open_expire')).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 177 | + expect(run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'hold_timeout')).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 178 | + expect(run(stateFor('claimed', 'piecework', { next_checkpoint_due_at: FUTURE }), 'checkpoint_miss')).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 179 | + expect(run(stateFor('in_contest', 'contest', { deadline_at: FUTURE }), 'contest_close')).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 180 | + expect(run(stateFor('submitted', 'piecework', { review_due_at: FUTURE }), 'review_auto_accept')).toMatchObject({ ok: false, code: 'state_conflict' }) | |
| 181 | + }) | |
| 182 | + | |
| 183 | + it('提交开启验收窗口:计件活 24h,擂台赛关闭后 72h', () => { | |
| 184 | + const submitted = run(stateFor('claimed'), 'submit') | |
| 185 | + expect(submitted.task.review_due_at).toEqual(new Date('2026-10-13T12:00:00.000Z')) | |
| 186 | + expect(effectTypes(submitted)).toEqual(['hold_end', 'task.submitted', 'job:review_warn', 'job:review_auto_accept']) | |
| 187 | + expect(run(stateFor('in_contest'), 'contest_close').task.review_due_at).toEqual(new Date('2026-10-15T12:00:00.000Z')) | |
| 188 | + }) | |
| 189 | + | |
| 190 | + it('驳回两次进返工,第 3 次进仲裁', () => { | |
| 191 | + expect(run(stateFor('submitted'), 'reject').task).toMatchObject({ status: 'rework', rework_count: 1, review_due_at: null }) | |
| 192 | + expect(run(stateFor('submitted', 'piecework', { rework_count: 1 }), 'reject').task).toMatchObject({ status: 'rework', rework_count: 2 }) | |
| 193 | + const third = run(stateFor('submitted', 'piecework', { rework_count: 2 }), 'reject') | |
| 194 | + expect(third.task).toMatchObject({ status: 'disputed', rework_count: 2 }) | |
| 195 | + expect(effectTypes(third)).toEqual(['dispute_open', 'job:dispute_arbitrate']) | |
| 196 | + }) | |
| 197 | + | |
| 198 | + it('结算与退款的托管动作', () => { | |
| 199 | + expect(run(stateFor('submitted'), 'accept').effects[0]).toEqual({ type: 'escrow_settle', winner_agent_id: 'ag_holder' }) | |
| 200 | + expect(run(stateFor('reviewing'), 'select_winner').effects[0]).toEqual({ type: 'escrow_settle', winner_agent_id: 'ag_entrant' }) | |
| 201 | + const noWinner = run(stateFor('reviewing'), 'review_auto_accept', worker, { winner_agent_id: null }) | |
| 202 | + expect(noWinner.task.status).toBe('refunded') | |
| 203 | + expect(noWinner.effects[0]).toEqual({ type: 'escrow_refund' }) | |
| 204 | + expect(run(stateFor('disputed'), 'resolve', operator, { outcome: 'refunded', winner_agent_id: null }).task.status).toBe('refunded') | |
| 205 | + for (const type of ['cancel', 'open_expire']) expect(run(stateFor('open'), type).effects).toEqual([{ type: 'escrow_refund' }]) | |
| 206 | + }) | |
| 207 | + | |
| 208 | + it('修改只能加赏金或延长截止', () => { | |
| 209 | + const edited = run(stateFor('open'), 'edit') | |
| 210 | + expect(edited.task).toMatchObject({ reward: 150, deadline_at: FUTURE }) | |
| 211 | + expect(effectTypes(edited)).toEqual(['escrow_topup', 'job:open_expire']) | |
| 212 | + expect(run(stateFor('open'), 'edit', publisher, { add_reward: 0, deadline_at: null })).toMatchObject({ ok: false, code: 'rule_violation' }) | |
| 213 | + expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'edit', publisher, { add_reward: 0, deadline_at: PAST })).toMatchObject({ ok: false, code: 'rule_violation' }) | |
| 214 | + }) | |
| 215 | + | |
| 216 | + it('争议只能由发布方或接单方发起', () => { | |
| 217 | + expect(run(stateFor('submitted'), 'dispute', holder).task.status).toBe('disputed') | |
| 218 | + expect(run(stateFor('reviewing'), 'dispute', entrant).task.status).toBe('disputed') | |
| 219 | + expect(run(stateFor('submitted'), 'dispute', taker)).toMatchObject({ ok: false, code: 'forbidden' }) | |
| 220 | + }) | |
| 221 | + | |
| 222 | + it('发布时的初始状态', () => { | |
| 223 | + expect(publishStatus({ require_lead_approval: true, publisher_role: 'company' })).toBe('pending_approval') | |
| 224 | + expect(publishStatus({ require_lead_approval: false, publisher_role: 'company' })).toBe('open') | |
| 225 | + expect(publishStatus({ require_lead_approval: true, publisher_role: 'member' })).toBe('open') | |
| 226 | + }) | |
| 227 | + | |
| 228 | + it('未知动作与非法行直接 throw', () => { | |
| 229 | + expect(() => transition(stateFor('open'), { type: 'teleport' }, worker, NOW)).toThrow(/Unhandled action: teleport, id: t_1/) | |
| 230 | + expect(() => stateFor('archived')).toThrow(/Unhandled status: archived, id: t_1/) | |
| 231 | + expect(() => stateFor('claimed', 'piecework', { holder_agent_id: null })).toThrow(/holder_agent_id missing/) | |
| 232 | + }) | |
| 233 | +}) | ... | ... |
pnpm-lock.yaml
| ... | ... | @@ -294,7 +294,11 @@ importers: |
| 294 | 294 | |
| 295 | 295 | packages/voko-clearing: {} |
| 296 | 296 | |
| 297 | - packages/voko-exchange: {} | |
| 297 | + packages/voko-exchange: | |
| 298 | + devDependencies: | |
| 299 | + vitest: | |
| 300 | + specifier: ^2.1.4 | |
| 301 | + version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0) | |
| 298 | 302 | |
| 299 | 303 | packages: |
| 300 | 304 | ... | ... |