Commit ff13178430c560f188de736768c0f74024a298b1

Authored by yibo
1 parent 9a6089c7

feat(exchange): 任务构造点 taskFromRow 与状态机 transition(),SM-01 穷举测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
packages/voko-exchange/package.json
... ... @@ -5,5 +5,11 @@
5 5 "type": "module",
6 6 "exports": {
7 7 ".": "./src/index.js"
  8 + },
  9 + "scripts": {
  10 + "test": "vitest run"
  11 + },
  12 + "devDependencies": {
  13 + "vitest": "^2.1.4"
8 14 }
9 15 }
... ...
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  
... ...