/** * 任务状态机:平台强制的规则只在这里写一遍。 * * `transition()` 是纯函数:只返回新任务与要做的副作用(持有记录、托管动作、事件、job), * 由 service 在锁住任务行的同一个事务里执行。它不读库、不读时钟。 * * 不在这里判的规则(需要读其它表,由 service 在同一事务里保证): * 重复报名(vk_task_entries UNIQUE)、转让请求是否 pending 且指向 actor、验收 checks 与清单的覆盖关系。 */ import { rarityForReward } from './rarity.js' import { TASK_STATUSES } from './task.js' /** 除发布(新建,见 `publishStatus`)外的全部动作。 */ export const TASK_ACTIONS = Object.freeze([ 'approve', 'reject_approval', 'claim', 'enter', 'cancel', 'open_expire', 'edit', 'checkpoint', 'checkpoint_miss', 'release', 'hold_timeout', 'transfer_accept', 'submit', 'accept', 'reject', 'review_auto_accept', 'contest_close', 'select_winner', 'dispute', 'resolve', ]) /** 每个 Agent 同时持有的计件活上限。 */ export const MAX_ACTIVE_HOLDS = 3 const HOUR_MS = 3600_000 /** * @typedef {import('./task.js').Task} Task * * @typedef {{ * task: Task, * guild: { checkpoint_interval_sec: number, piece_review_window_sec: number, contest_review_window_sec: number }, * publisher_owner_user_id: string, * entry_count: number, * }} TaskState 任务行 + 所属公会的时间规则 + 发布方 Agent 的主人 + 擂台赛当前报名数,由 service 在事务内读出。 * * @typedef {{ kind: 'agent', agent_id: string, owner_user_id: string, max_rarity: number, active_hold_count: number, is_entrant: boolean }} AgentActor * @typedef {AgentActor | { kind: 'lead', user_id: string } | { kind: 'operator', user_id: string } | { kind: 'worker' }} Actor * * @typedef {( * { type: 'approve' } | { type: 'reject_approval' } | { type: 'claim' } | { type: 'enter' } | { type: 'cancel' } | * { type: 'open_expire' } | { type: 'edit', add_reward: number, deadline_at: Date|null } | { type: 'checkpoint' } | * { type: 'checkpoint_miss' } | { type: 'release' } | { type: 'hold_timeout' } | { type: 'transfer_accept' } | * { type: 'submit' } | { type: 'accept' } | { type: 'reject' } | { type: 'review_auto_accept', winner_agent_id: string|null } | * { type: 'contest_close' } | { type: 'select_winner', winner_agent_id: string } | { type: 'dispute' } | * { type: 'resolve', outcome: 'settled'|'refunded', winner_agent_id: string|null } * )} Action * * @typedef {( * { type: 'hold_start', agent_id: string } | * { type: 'hold_end', reason: 'submitted'|'released'|'timeout'|'transferred' } | * { type: 'hold_checkpoint' } | * { type: 'entry_add', agent_id: string } | * { type: 'entry_remove', agent_id: string } | * { type: 'dispute_close', agent_id: string } | * { type: 'escrow_topup', amount: number } | * { type: 'escrow_refund' } | * { type: 'escrow_settle', winner_agent_id: string } | * { type: 'dispute_open', opened_by_agent_id: string } | * { type: 'event', event_type: string, target_agent_id: string|null } | * { type: 'job', kind: string, run_at: Date } * )} Effect `event.target_agent_id` 为 null 表示发给整个公会。 * * @typedef {{ ok: true, task: Task, effects: Effect[] } | { ok: false, code: 'state_conflict'|'rule_violation'|'forbidden', hint: string }} TransitionResult */ /** * 发布时的初始状态:公会开启发布前审核且发布方是公司 Agent 时先进待审核。 * @param {{ require_lead_approval: boolean, publisher_role: 'company'|'member' }} input * @returns {'open'|'pending_approval'} */ export function publishStatus({ require_lead_approval, publisher_role }) { switch (publisher_role) { case 'company': return require_lead_approval ? 'pending_approval' : 'open' case 'member': return 'open' default: throw new Error(`Unhandled publisher role: ${publisher_role}`) } } /** * @param {TaskState} state * @param {Action} action * @param {Actor} actor * @param {Date} now * @returns {TransitionResult} */ export function transition(state, action, actor, now) { const { task } = state if (!TASK_ACTIONS.includes(action.type)) throw new Error(`Unhandled action: ${action.type}, id: ${task.id}`) const handlers = TRANSITIONS[task.status] if (handlers === undefined) throw new Error(`Unhandled status: ${task.status}, id: ${task.id}`) const handler = handlers[action.type] if (handler === undefined) return fail('state_conflict', `Task is ${task.status}; ${action.type} is not allowed. Re-fetch the task.`) return handler(state, /** @type {any} */ (action), actor, now) } /** @typedef {(state: TaskState, action: any, actor: Actor, now: Date) => TransitionResult} Handler */ /** @type {Record>>} */ const TRANSITIONS = { pending_approval: { approve({ task }, _action, actor) { if (actor.kind !== 'lead') return notAllowed('Only the guild lead can approve.') return ok({ ...task, status: 'open' }, [event('task.published', null), ...openJobs(task)]) }, reject_approval({ task }, _action, actor) { if (actor.kind !== 'lead') return notAllowed('Only the guild lead can reject.') return ok({ ...task, status: 'cancelled' }, [{ type: 'escrow_refund' }]) }, }, open: { claim(state, _action, actor, now) { const { task } = state if (task.template !== 'piecework') return fail('rule_violation', 'This task is a contest; use enter.') const refused = takerRefusal(state, actor) if (refused !== null) return refused const taker = /** @type {AgentActor} */ (actor) if (taker.active_hold_count >= MAX_ACTIVE_HOLDS) return fail('state_conflict', `You already hold ${MAX_ACTIVE_HOLDS} piecework tasks.`) return ok(hold(state, taker.agent_id, now), [ { type: 'hold_start', agent_id: taker.agent_id }, event('task.claimed', task.publisher_agent_id), ...checkpointJobs(state, now), job('hold_timeout', task.deadline_at), ]) }, enter(state, _action, actor) { const { task } = state if (task.template !== 'contest') return fail('rule_violation', 'This task is piecework; use claim.') const refused = takerRefusal(state, actor) if (refused !== null) return refused return ok({ ...task, status: 'in_contest' }, [{ type: 'entry_add', agent_id: /** @type {AgentActor} */ (actor).agent_id }]) }, cancel({ task }, _action, actor) { if (!isPublisher(task, actor)) return notAllowed('Only the publisher can cancel.') return ok({ ...task, status: 'cancelled' }, [{ type: 'escrow_refund' }]) }, open_expire({ task }, _action, actor, now) { if (actor.kind !== 'worker') return notAllowed('Only the platform can expire a task.') if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') return ok({ ...task, status: 'cancelled' }, [{ type: 'escrow_refund' }]) }, edit({ task }, action, actor) { if (!isPublisher(task, actor)) return notAllowed('Only the publisher can edit.') if (!Number.isSafeInteger(action.add_reward) || action.add_reward < 0) throw new Error(`edit.add_reward invalid: ${action.add_reward}, id: ${task.id}`) if (action.add_reward === 0 && action.deadline_at === null) return fail('rule_violation', 'Nothing to change: add reward or extend the deadline.') if (action.deadline_at !== null && action.deadline_at <= task.deadline_at) return fail('rule_violation', 'The deadline can only be extended.') const reward = task.reward + action.add_reward const next = { ...task, reward, rarity: rarityForReward(reward), deadline_at: action.deadline_at === null ? task.deadline_at : action.deadline_at } return ok(next, [ ...(action.add_reward === 0 ? [] : [/** @type {Effect} */ ({ type: 'escrow_topup', amount: action.add_reward })]), ...(action.deadline_at === null ? [] : openJobs(next)), ]) }, }, claimed: { checkpoint(state, _action, actor, now) { if (!isHolder(state.task, actor)) return notAllowed('Only the current holder can post a checkpoint.') return ok({ ...state.task, next_checkpoint_due_at: checkpointDue(state, now), missed_checkpoints: 0 }, [ { type: 'hold_checkpoint' }, event('task.checkpoint', null), ...checkpointJobs(state, now), ]) }, checkpoint_miss(state, _action, actor, now) { const { task } = state if (actor.kind !== 'worker') return notAllowed('Only the platform can record a missed checkpoint.') const due = /** @type {Date} */ (task.next_checkpoint_due_at) if (now < due) return fail('state_conflict', 'Checkpoint is not overdue.') if (task.missed_checkpoints >= 1) return released(task, 'timeout', now) // 第一次缺失只告警:从原到期时刻顺延一个间隔。 return ok({ ...task, next_checkpoint_due_at: checkpointDue(state, due), missed_checkpoints: 1 }, checkpointJobs(state, due)) }, release({ task }, _action, actor, now) { if (!isHolder(task, actor)) return notAllowed('Only the current holder can release.') return released(task, 'released', now) }, hold_timeout({ task }, _action, actor, now) { if (actor.kind !== 'worker') return notAllowed('Only the platform can time out a hold.') if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') return released(task, 'timeout', now) }, transfer_accept(state, _action, actor, now) { const refused = takerRefusal(state, actor) if (refused !== null) return refused const taker = /** @type {AgentActor} */ (actor) if (taker.agent_id === state.task.holder_agent_id) return fail('state_conflict', 'You already hold this task.') if (taker.active_hold_count >= MAX_ACTIVE_HOLDS) return fail('state_conflict', `You already hold ${MAX_ACTIVE_HOLDS} piecework tasks.`) return ok(hold(state, taker.agent_id, now), [ { type: 'hold_end', reason: 'transferred' }, { type: 'hold_start', agent_id: taker.agent_id }, ...checkpointJobs(state, now), ]) }, submit(state, _action, actor, now) { if (!isHolder(state.task, actor)) return notAllowed('Only the current holder can submit.') return ok(...submitted(state, now, [{ type: 'hold_end', reason: 'submitted' }])) }, }, in_contest: { enter(state, _action, actor) { const refused = takerRefusal(state, actor) if (refused !== null) return refused if (/** @type {AgentActor} */ (actor).is_entrant) return fail('state_conflict', 'You have already entered this contest.') return ok(state.task, [{ type: 'entry_add', agent_id: /** @type {AgentActor} */ (actor).agent_id }]) }, submit({ task }, _action, actor) { if (actor.kind !== 'agent' || !actor.is_entrant) return notAllowed('Enter the contest before submitting.') // 提交封存到截止,不通知发布方。 return ok(task, []) }, // 参赛者退赛:撤掉自己的报名与提交;最后一个人退出后任务回到 open。 release({ task, entry_count }, _action, actor) { if (actor.kind !== 'agent' || !actor.is_entrant) return notAllowed('Only an entrant can withdraw from a contest.') return ok(entry_count <= 1 ? { ...task, status: 'open' } : task, [{ type: 'entry_remove', agent_id: actor.agent_id }]) }, contest_close(state, _action, actor, now) { const { task } = state if (actor.kind !== 'worker') return notAllowed('Only the platform can close a contest.') if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') const reviewDue = new Date(now.getTime() + state.guild.contest_review_window_sec * 1000) return ok({ ...task, status: 'reviewing', review_due_at: reviewDue }, [event('task.submitted', task.publisher_agent_id), ...reviewJobs(reviewDue)]) }, }, submitted: { accept({ task }, _action, actor, now) { if (!isPublisher(task, actor)) return notAllowed('Only the publisher can review.') const holder = /** @type {string} */ (task.holder_agent_id) return ok(settle(task, now), [{ type: 'escrow_settle', winner_agent_id: holder }, event('task.accepted', holder)]) }, review_auto_accept({ task }, _action, actor, now) { if (actor.kind !== 'worker') return notAllowed('Only the platform can auto-accept.') if (now < /** @type {Date} */ (task.review_due_at)) return fail('state_conflict', 'Review window has not expired.') const holder = /** @type {string} */ (task.holder_agent_id) return ok(settle(task, now), [ { type: 'escrow_settle', winner_agent_id: holder }, event('task.auto_accepted', task.publisher_agent_id), event('task.auto_accepted', holder), ]) }, reject({ task }, _action, actor, now) { if (!isPublisher(task, actor) || actor.kind !== 'agent') return notAllowed('Only the publisher can review.') const holder = /** @type {string} */ (task.holder_agent_id) // 第 3 次驳回直接进仲裁。 if (task.rework_count >= 2) return ok({ ...task, status: 'disputed' }, disputeEffects(actor.agent_id, now)) return ok({ ...task, status: 'rework', rework_count: task.rework_count + 1, review_due_at: null }, [event('task.rejected', holder)]) }, dispute: openDispute, release: holderRelease, }, reviewing: { select_winner({ task }, action, actor, now) { if (!isPublisher(task, actor)) return notAllowed('Only the publisher can select a winner.') return ok(settle(task, now), [{ type: 'escrow_settle', winner_agent_id: action.winner_agent_id }, event('task.accepted', action.winner_agent_id)]) }, review_auto_accept({ task }, action, actor, now) { if (actor.kind !== 'worker') return notAllowed('Only the platform can auto-accept.') if (now < /** @type {Date} */ (task.review_due_at)) return fail('state_conflict', 'Review window has not expired.') // 没有任何提交全部通过清单:退款。 if (action.winner_agent_id === null) return ok({ ...task, status: 'refunded' }, [{ type: 'escrow_refund' }, event('task.auto_accepted', task.publisher_agent_id)]) return ok(settle(task, now), [ { type: 'escrow_settle', winner_agent_id: action.winner_agent_id }, event('task.auto_accepted', task.publisher_agent_id), event('task.auto_accepted', action.winner_agent_id), ]) }, dispute: openDispute, }, rework: { submit(state, _action, actor, now) { if (!isHolder(state.task, actor)) return notAllowed('Only the current holder can submit.') return ok(...submitted(state, now, [])) }, dispute: openDispute, release: holderRelease, }, disputed: { resolve({ task }, action, actor, now) { if (actor.kind !== 'operator') return notAllowed('Only platform operators can resolve a dispute.') const notify = [event('dispute.resolved', task.publisher_agent_id), ...(task.holder_agent_id === null ? [] : [event('dispute.resolved', task.holder_agent_id)])] switch (action.outcome) { case 'settled': if (action.winner_agent_id === null) throw new Error(`resolve.winner_agent_id missing for settled, id: ${task.id}`) return ok(settle(task, now), [{ type: 'escrow_settle', winner_agent_id: action.winner_agent_id }, ...notify]) case 'refunded': return ok({ ...task, status: 'refunded' }, [{ type: 'escrow_refund' }, ...notify]) default: throw new Error(`Unhandled outcome: ${action.outcome}, id: ${task.id}`) } }, // 持有人在仲裁中放弃:争议随之关闭,任务回到看板。 release({ task }, _action, actor, now) { if (!isHolder(task, actor) || actor.kind !== 'agent') return notAllowed('Only the current holder can release.') const reopened = reopen(task, now) return ok(reopened, [{ type: 'dispute_close', agent_id: actor.agent_id }, ...releaseEffects(task, reopened, 'released')]) }, }, settled: {}, cancelled: {}, refunded: {}, } if (Object.keys(TRANSITIONS).length !== TASK_STATUSES.length) throw new Error('TRANSITIONS must cover every task status') /** * 持有人放弃已提交或返工中的任务:只要还没结算,都可以释放回看板。 * @type {Handler} */ function holderRelease({ task }, _action, actor, now) { if (!isHolder(task, actor)) return notAllowed('Only the current holder can release.') return released(task, 'released', now) } /** @type {Handler} */ function openDispute({ task }, _action, actor, now) { if (actor.kind !== 'agent') return notAllowed('Only the publisher or a taker can open a dispute.') const isParty = actor.agent_id === task.publisher_agent_id || actor.agent_id === task.holder_agent_id || actor.is_entrant if (!isParty) return notAllowed('Only the publisher or a taker can open a dispute.') return ok({ ...task, status: 'disputed' }, disputeEffects(actor.agent_id, now)) } /** * 接单方通用校验:必须是 Agent、不能接自己或同主人 Agent 的任务、稀有度不超过等级上限。 * @param {TaskState} state * @param {Actor} actor * @returns {TransitionResult|null} */ function takerRefusal({ task, publisher_owner_user_id }, actor) { if (actor.kind !== 'agent') return notAllowed('Only agents can take tasks.') if (actor.agent_id === task.publisher_agent_id || actor.owner_user_id === publisher_owner_user_id) { return fail('state_conflict', 'You cannot take a task published by you or by another agent of your owner.') } if (task.rarity > actor.max_rarity) return fail('rule_violation', `Task rarity ${task.rarity} is above your level cap ${actor.max_rarity}.`) return null } /** * @param {Task} task * @param {Actor} actor */ function isPublisher(task, actor) { return actor.kind === 'agent' && actor.agent_id === task.publisher_agent_id } /** * @param {Task} task * @param {Actor} actor */ function isHolder(task, actor) { return actor.kind === 'agent' && actor.agent_id === task.holder_agent_id } /** * @param {TaskState} state * @param {Date} from */ function checkpointDue(state, from) { return new Date(from.getTime() + state.guild.checkpoint_interval_sec * 1000) } /** * @param {TaskState} state * @param {string} agentId * @param {Date} now * @returns {Task} */ function hold(state, agentId, now) { return { ...state.task, status: 'claimed', holder_agent_id: agentId, next_checkpoint_due_at: checkpointDue(state, now), missed_checkpoints: 0 } } /** * @param {Task} task * @param {'released'|'timeout'} reason * @param {Date} now * @returns {TransitionResult} */ function released(task, reason, now) { const reopened = reopen(task, now) return ok(reopened, releaseEffects(task, reopened, reason)) } /** * 任务回到 open(未结算前,持有人在任何状态都可以放弃)。若此时已过截止,按原先给的工期(created_at → deadline_at)从现在起重新计时, * 否则任务既接不了也不会过期,会卡死。 * @param {Task} task * @param {Date} now * @returns {Task} */ function reopen(task, now) { const deadline = now < task.deadline_at ? task.deadline_at : new Date(now.getTime() + (task.deadline_at.getTime() - task.created_at.getTime())) // 新接手的人从零开始计返工次数。 return { ...task, status: 'open', deadline_at: deadline, holder_agent_id: null, next_checkpoint_due_at: null, missed_checkpoints: 0, rework_count: 0, review_due_at: null } } /** * @param {Task} task * @param {Date} now * @returns {Task} */ function settle(task, now) { return { ...task, status: 'settled', settled_at: now } } /** * @param {TaskState} state * @param {Date} now * @param {Effect[]} before * @returns {[Task, Effect[]]} */ function submitted(state, now, before) { const { task } = state const reviewDue = new Date(now.getTime() + state.guild.piece_review_window_sec * 1000) return [ { ...task, status: 'submitted', next_checkpoint_due_at: null, missed_checkpoints: 0, review_due_at: reviewDue }, [...before, event('task.submitted', task.publisher_agent_id), ...reviewJobs(reviewDue)], ] } /** * @param {Task} task 释放前的任务 * @param {Task} reopened 释放后的任务 * @param {'released'|'timeout'} reason * @returns {Effect[]} */ function releaseEffects(task, reopened, reason) { return [ { type: 'hold_end', reason }, event('task.released', /** @type {string} */ (task.holder_agent_id)), event('task.released', task.publisher_agent_id), // 截止被顺延时要按新截止重新排「过期无人接」。 ...(reopened.deadline_at.getTime() === task.deadline_at.getTime() ? [] : openJobs(reopened)), ] } /** * @param {string} openedBy * @param {Date} now * @returns {Effect[]} */ function disputeEffects(openedBy, now) { return [{ type: 'dispute_open', opened_by_agent_id: openedBy }, job('dispute_arbitrate', now)] } /** * 任务上板后的到期 job:过期无人接;擂台赛另加到点关闭。 * @param {Task} task * @returns {Effect[]} */ function openJobs(task) { switch (task.template) { case 'piecework': return [job('open_expire', task.deadline_at)] case 'contest': return [job('open_expire', task.deadline_at), job('contest_close', task.deadline_at)] default: throw new Error(`Unhandled template: ${task.template}, id: ${task.id}`) } } /** * @param {TaskState} state * @param {Date} from * @returns {Effect[]} */ function checkpointJobs(state, from) { const due = checkpointDue(state, from) return [job('checkpoint_warn', new Date(due.getTime() - HOUR_MS)), job('checkpoint_miss', due)] } /** * @param {Date} reviewDue * @returns {Effect[]} */ function reviewJobs(reviewDue) { return [job('review_warn', new Date(reviewDue.getTime() - 2 * HOUR_MS)), job('review_auto_accept', reviewDue)] } /** * @param {string} eventType * @param {string|null} targetAgentId * @returns {Effect} */ function event(eventType, targetAgentId) { return { type: 'event', event_type: eventType, target_agent_id: targetAgentId } } /** * @param {string} kind * @param {Date} runAt * @returns {Effect} */ function job(kind, runAt) { return { type: 'job', kind, run_at: runAt } } /** * @param {Task} task * @param {Effect[]} effects * @returns {TransitionResult} */ function ok(task, effects) { return { ok: true, task, effects } } /** * @param {'state_conflict'|'rule_violation'|'forbidden'} code * @param {string} hint * @returns {TransitionResult} */ function fail(code, hint) { return { ok: false, code, hint } } /** * @param {string} hint * @returns {TransitionResult} */ function notAllowed(hint) { return fail('forbidden', hint) }