Commit 1d74085c5e876ecb590b9fc051e3f6618db1e569
1 parent
4839340e
feat(exchange): W3——检查点、接力、转让、提交、按清单验收、结算、仲裁裁决、发布前审核
- 状态机:任务过截止后回到 open 时按原工期顺延截止并重排过期 job - 纯函数 splitRelayPayout(SM-02)与 reviewViolation - /v1:checkpoints、submit、review、release、dispute、transfer(含 accept / decline) - 网页与后台接口:待审核任务、仲裁队列与裁决;认领可选公司 Agent(仅公会负责人) - clearing:按发放明细结算并加 XP;worker 接入自动确认与转让过期 - 场景 f3 与扩充后的 f1 / f2 / timers;待审核页;登录过期回登录页 - 擂台赛 AI 选赢家与 AI 仲裁意见待大模型装配 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
30 changed files
with
1023 additions
and
82 deletions
apps/voko-server/package.json
| ... | ... | @@ -16,6 +16,7 @@ |
| 16 | 16 | "verify:f0": "node tools/scenarios/voko-alpha/f0-onboarding.mjs", |
| 17 | 17 | "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs", |
| 18 | 18 | "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs", |
| 19 | + "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs", | |
| 19 | 20 | "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs", |
| 20 | 21 | "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs", |
| 21 | 22 | "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs" | ... | ... |
apps/voko-server/src/routes/access.js
| ... | ... | @@ -11,12 +11,18 @@ const ListQuery = v.object({ |
| 11 | 11 | const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) }) |
| 12 | 12 | const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) }) |
| 13 | 13 | const ClaimParams = v.object({ token: v.pipe(v.string(), v.minLength(1)) }) |
| 14 | -const ClaimBody = v.object({ verification_code: v.pipe(v.string(), v.minLength(1)), guild_id: v.pipe(v.string(), v.minLength(1)) }) | |
| 14 | +const ClaimBody = v.object({ verification_code: v.pipe(v.string(), v.minLength(1)), guild_id: v.pipe(v.string(), v.minLength(1)), role: v.optional(v.picklist(['member', 'company']), 'member') }) | |
| 15 | +const WindowSec = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(30 * 86400)) | |
| 15 | 16 | const GuildBody = v.object({ |
| 16 | 17 | name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), |
| 17 | 18 | slug: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-]{1,62}$/)), |
| 18 | 19 | lead_user_id: v.pipe(v.string(), v.minLength(1)), |
| 19 | 20 | require_lead_approval: v.boolean(), |
| 21 | + // 时间规则是公会级产品配置(默认 6h / 24h / 72h / 24h)。 | |
| 22 | + checkpoint_interval_sec: v.optional(WindowSec), | |
| 23 | + piece_review_window_sec: v.optional(WindowSec), | |
| 24 | + contest_review_window_sec: v.optional(WindowSec), | |
| 25 | + transfer_ttl_sec: v.optional(WindowSec), | |
| 20 | 26 | }) |
| 21 | 27 | |
| 22 | 28 | /** |
| ... | ... | @@ -58,7 +64,7 @@ export function registerAccessRoutes(app, services) { |
| 58 | 64 | const user = await requireUser(request, services) |
| 59 | 65 | const params = v.parse(ClaimParams, request.params) |
| 60 | 66 | const body = v.parse(ClaimBody, request.body) |
| 61 | - const result = await agentAccess.claim(request.ctx, { claim_token: params.token, user_id: user.id, verification_code: body.verification_code, guild_id: body.guild_id }) | |
| 67 | + const result = await agentAccess.claim(request.ctx, { claim_token: params.token, user_id: user.id, verification_code: body.verification_code, guild_id: body.guild_id, role: body.role }) | |
| 62 | 68 | if (!result.ok) throw codedError(result.message, result.code) |
| 63 | 69 | return { agent: agentView(result.agent) } |
| 64 | 70 | }) | ... | ... |
apps/voko-server/src/routes/owner.js
| ... | ... | @@ -5,6 +5,9 @@ import { agentView, IdempotencyKey, PublishBody, REFUSAL_STATUS, taskView } from |
| 5 | 5 | |
| 6 | 6 | const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) |
| 7 | 7 | const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) |
| 8 | +const TaskIdParams = v.object({ task_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) | |
| 9 | +const ApprovalBody = v.object({ decision: v.picklist(['approve', 'reject']) }) | |
| 10 | +const ResolveBody = v.object({ outcome: v.picklist(['settled', 'refunded']), winner_agent_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))) }) | |
| 8 | 11 | const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) |
| 9 | 12 | |
| 10 | 13 | /** |
| ... | ... | @@ -80,4 +83,41 @@ export function registerOwnerRoutes(app, services) { |
| 80 | 83 | if (!result.ok) return reply.code(REFUSAL_STATUS[result.code]).send({ error: { code: result.code, message: result.hint, retryable: false } }) |
| 81 | 84 | return { task: taskView(result.task) } |
| 82 | 85 | }) |
| 86 | + | |
| 87 | + /** | |
| 88 | + * 业务拒绝 → 网页接口的错误形状。 | |
| 89 | + * @param {import('fastify').FastifyReply} reply | |
| 90 | + * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal | |
| 91 | + */ | |
| 92 | + const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) | |
| 93 | + | |
| 94 | + // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。 | |
| 95 | + app.get('/api/voko/approvals', async (request) => { | |
| 96 | + const user = await requireUser(request, services) | |
| 97 | + return { tasks: (await tasks.listPendingApprovals(request.ctx, { user_id: user.id })).map(taskView) } | |
| 98 | + }) | |
| 99 | + | |
| 100 | + app.post('/api/voko/approvals/:task_id', async (request, reply) => { | |
| 101 | + const user = await requireUser(request, services) | |
| 102 | + const params = v.parse(TaskIdParams, request.params) | |
| 103 | + const body = v.parse(ApprovalBody, request.body) | |
| 104 | + const result = await tasks.decideApproval(request.ctx, { user_id: user.id, task_id: params.task_id, decision: body.decision }) | |
| 105 | + return result.ok ? { task: taskView(result.task) } : refused(reply, result) | |
| 106 | + }) | |
| 107 | + | |
| 108 | + // 运营:仲裁队列与裁决。 | |
| 109 | + app.get('/api/voko/admin/disputes', async (request) => { | |
| 110 | + const user = await requireUser(request, services) | |
| 111 | + if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN') | |
| 112 | + return { disputes: (await tasks.listOpenDisputes(request.ctx)).map((item) => ({ dispute_id: item.dispute_id, status: item.status, opened_by_agent_id: item.opened_by_agent_id, created_at: item.created_at.toISOString(), task: taskView(item.task) })) } | |
| 113 | + }) | |
| 114 | + | |
| 115 | + app.post('/api/voko/admin/disputes/:task_id/resolve', async (request, reply) => { | |
| 116 | + const user = await requireUser(request, services) | |
| 117 | + if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN') | |
| 118 | + const params = v.parse(TaskIdParams, request.params) | |
| 119 | + const body = v.parse(ResolveBody, request.body) | |
| 120 | + const result = await tasks.resolveDispute(request.ctx, { user_id: user.id, task_id: params.task_id, outcome: body.outcome, winner_agent_id: body.winner_agent_id ?? null }) | |
| 121 | + return result.ok ? { task: taskView(result.task) } : refused(reply, result) | |
| 122 | + }) | |
| 83 | 123 | } | ... | ... |
apps/voko-server/src/routes/v1.js
| ... | ... | @@ -33,6 +33,24 @@ const ListQuery = v.object({ |
| 33 | 33 | cursor: v.optional(v.pipe(v.string(), v.regex(/^[^|]+\|[^|]+$/))), |
| 34 | 34 | limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50'), |
| 35 | 35 | }) |
| 36 | +const HttpsUrl = v.pipe(v.string(), v.url(), v.startsWith('https://'), v.maxLength(2048)) | |
| 37 | +const CheckpointBody = v.object({ | |
| 38 | + progress: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(100)), | |
| 39 | + note: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(2000))), | |
| 40 | + links: v.optional(v.pipe(v.array(HttpsUrl), v.maxLength(10)), []), | |
| 41 | +}) | |
| 42 | +// 交付物只接受链接,平台不托管文件。 | |
| 43 | +const SubmitBody = v.object({ | |
| 44 | + summary: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)), | |
| 45 | + artifacts: v.pipe(v.array(v.object({ type: v.literal('link'), url: HttpsUrl, note: v.optional(v.pipe(v.string(), v.maxLength(500))) })), v.minLength(1), v.maxLength(20)), | |
| 46 | +}) | |
| 47 | +const ReviewBody = v.object({ | |
| 48 | + action: v.picklist(['accept', 'reject', 'select_winner']), | |
| 49 | + checks: v.pipe(v.array(v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(32)), pass: v.boolean(), reason: v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) })), v.minLength(1), v.maxLength(5)), | |
| 50 | + reason: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(4000))), | |
| 51 | + submission_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))), | |
| 52 | +}) | |
| 53 | +const TransferBody = v.object({ to_agent_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) | |
| 36 | 54 | const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) |
| 37 | 55 | const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) |
| 38 | 56 | export const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100)) |
| ... | ... | @@ -155,7 +173,13 @@ export function registerV1Routes(app, services) { |
| 155 | 173 | const params = v.parse(TaskParams, request.params) |
| 156 | 174 | const result = await tasks.get(request.ctx, { agent: agentOf(request), task_id: params.id }) |
| 157 | 175 | if (!result.ok) return refused(reply, result) |
| 158 | - return ok({ ...taskView(result.task), acceptance: result.acceptance, entry_count: result.entry_count }) | |
| 176 | + return ok({ | |
| 177 | + ...taskView(result.task), | |
| 178 | + acceptance: result.acceptance, | |
| 179 | + entry_count: result.entry_count, | |
| 180 | + checkpoints: result.checkpoints.map((item) => ({ id: item.id, agent_id: item.agent_id, progress: item.progress, note: item.note, links: item.links, created_at: item.created_at.toISOString() })), | |
| 181 | + submissions: result.submissions.map((item) => ({ id: item.id, agent_id: item.agent_id, round: item.round, summary: item.summary, artifacts: item.artifacts, created_at: item.created_at.toISOString() })), | |
| 182 | + }) | |
| 159 | 183 | }) |
| 160 | 184 | |
| 161 | 185 | v1.patch('/tasks/:id', async (request, reply) => { |
| ... | ... | @@ -165,13 +189,52 @@ export function registerV1Routes(app, services) { |
| 165 | 189 | return acted(reply, await tasks.act(request.ctx, { agent: agentOf(request), task_id: params.id, action: { type: 'edit', add_reward: body.add_reward ?? 0, deadline_at: body.deadline ?? null }, idempotency_key: key })) |
| 166 | 190 | }) |
| 167 | 191 | |
| 168 | - for (const type of /** @type {const} */ (['cancel', 'claim', 'enter'])) { | |
| 192 | + for (const type of /** @type {const} */ (['cancel', 'claim', 'enter', 'release', 'dispute'])) { | |
| 169 | 193 | v1.post(`/tasks/:id/${type}`, async (request, reply) => { |
| 170 | 194 | const params = v.parse(TaskParams, request.params) |
| 171 | 195 | return acted(reply, await tasks.act(request.ctx, { agent: agentOf(request), task_id: params.id, action: { type } })) |
| 172 | 196 | }) |
| 173 | 197 | } |
| 174 | 198 | |
| 199 | + v1.post('/tasks/:id/checkpoints', async (request, reply) => { | |
| 200 | + const params = v.parse(TaskParams, request.params) | |
| 201 | + const body = v.parse(CheckpointBody, request.body) | |
| 202 | + const result = await tasks.checkpoint(request.ctx, { agent: agentOf(request), task_id: params.id, progress: body.progress, note: body.note ?? null, links: body.links }) | |
| 203 | + if (!result.ok) return refused(reply, result) | |
| 204 | + return ok({ ...result.recorded, next_checkpoint_due: taskView(result.task).next_checkpoint_due }) | |
| 205 | + }) | |
| 206 | + | |
| 207 | + v1.post('/tasks/:id/submit', async (request, reply) => { | |
| 208 | + const params = v.parse(TaskParams, request.params) | |
| 209 | + const body = v.parse(SubmitBody, request.body) | |
| 210 | + const result = await tasks.submit(request.ctx, { agent: agentOf(request), task_id: params.id, summary: body.summary, artifacts: body.artifacts }) | |
| 211 | + if (!result.ok) return refused(reply, result) | |
| 212 | + return ok({ ...result.recorded, status: result.task.status, review_due: taskView(result.task).review_due }) | |
| 213 | + }) | |
| 214 | + | |
| 215 | + v1.post('/tasks/:id/review', async (request, reply) => { | |
| 216 | + const params = v.parse(TaskParams, request.params) | |
| 217 | + const body = v.parse(ReviewBody, request.body) | |
| 218 | + const result = await tasks.review(request.ctx, { agent: agentOf(request), task_id: params.id, action: body.action, checks: body.checks, reason: body.reason ?? null, submission_id: body.submission_id ?? null }) | |
| 219 | + if (!result.ok) return refused(reply, result) | |
| 220 | + return ok({ ...result.recorded, ...taskView(result.task) }) | |
| 221 | + }) | |
| 222 | + | |
| 223 | + v1.post('/tasks/:id/transfer', async (request, reply) => { | |
| 224 | + const params = v.parse(TaskParams, request.params) | |
| 225 | + const body = v.parse(TransferBody, request.body) | |
| 226 | + const result = await tasks.requestTransfer(request.ctx, { agent: agentOf(request), task_id: params.id, to_agent_id: body.to_agent_id }) | |
| 227 | + if (!result.ok) return refused(reply, result) | |
| 228 | + return ok({ transfer_id: result.transfer_id, status: 'pending', expires_at: result.expires_at.toISOString() }) | |
| 229 | + }) | |
| 230 | + | |
| 231 | + for (const decision of /** @type {const} */ (['accept', 'decline'])) { | |
| 232 | + v1.post(`/tasks/:id/transfer/${decision}`, async (request, reply) => { | |
| 233 | + const params = v.parse(TaskParams, request.params) | |
| 234 | + return acted(reply, await tasks.answerTransfer(request.ctx, { agent: agentOf(request), task_id: params.id, decision })) | |
| 235 | + }) | |
| 236 | + } | |
| 237 | + | |
| 175 | 238 | v1.get('/tasks/:id/comments', async (request, reply) => { |
| 176 | 239 | const params = v.parse(TaskParams, request.params) |
| 177 | 240 | const result = await tasks.listComments(request.ctx, { agent: agentOf(request), task_id: params.id }) | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs
| ... | ... | @@ -117,20 +117,21 @@ function createWorld(name, db) { |
| 117 | 117 | return owner |
| 118 | 118 | } |
| 119 | 119 | |
| 120 | - async function createGuild(tag, leadUserId) { | |
| 121 | - const guild = await call('POST', '/api/voko/admin/guilds', { token: await admin(), body: { name: `${name} ${tag}`, slug: `${name}-${tag}-${run}`, lead_user_id: leadUserId, require_lead_approval: false } }) | |
| 120 | + /** `settings` 可覆盖 require_lead_approval 与四个时间规则(分钟级 / 秒级的公会用于时间类用例)。 */ | |
| 121 | + async function createGuild(tag, leadUserId, settings = {}) { | |
| 122 | + const guild = await call('POST', '/api/voko/admin/guilds', { token: await admin(), body: { name: `${name} ${tag}`, slug: `${name}-${tag}-${run}`, lead_user_id: leadUserId, require_lead_approval: false, ...settings } }) | |
| 122 | 123 | check(`建公会 ${tag}`, guild.status === 200, guild) |
| 123 | 124 | guildIds.push(guild.json.guild.id) |
| 124 | 125 | return guild.json.guild.id |
| 125 | 126 | } |
| 126 | 127 | |
| 127 | 128 | /** 注册一个 Agent 并由主人认领进公会;返回 { id, key }。 */ |
| 128 | - async function createAgent(tag, owner, guildId) { | |
| 129 | + async function createAgent(tag, owner, guildId, role = 'member') { | |
| 129 | 130 | const registered = await call('POST', '/v1/agents/register', { body: { name: `${name}-${tag}-${run}`, skills: ['writing'] } }) |
| 130 | 131 | check(`Agent ${tag}:注册`, registered.status === 200, registered) |
| 131 | 132 | const data = registered.json.data |
| 132 | 133 | agentIds.push(data.agent_id) |
| 133 | - const claimed = await call('POST', `/api/voko/claims/${data.claim_url.split('/claim/')[1]}`, { token: owner.token, body: { verification_code: data.verification_code, guild_id: guildId } }) | |
| 134 | + const claimed = await call('POST', `/api/voko/claims/${data.claim_url.split('/claim/')[1]}`, { token: owner.token, body: { verification_code: data.verification_code, guild_id: guildId, role } }) | |
| 134 | 135 | check(`Agent ${tag}:认领`, claimed.status === 200 && claimed.json.agent.status === 'active', claimed) |
| 135 | 136 | return { id: data.agent_id, key: data.api_key } |
| 136 | 137 | } |
| ... | ... | @@ -154,7 +155,8 @@ function createWorld(name, db) { |
| 154 | 155 | const taskIds = guildIds.length === 0 ? [] : (await db('vk_tasks').whereIn('guild_id', guildIds).select('id')).map((row) => row.id) |
| 155 | 156 | const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) |
| 156 | 157 | if (taskIds.length > 0) { |
| 157 | - for (const table of ['vk_task_acceptance_items', 'vk_task_holds', 'vk_task_entries', 'vk_comments', 'vk_escrow_payouts', 'vk_escrows']) await db(table).whereIn('task_id', taskIds).del() | |
| 158 | + await db('vk_review_checks').whereIn('review_id', db('vk_reviews').whereIn('task_id', taskIds).select('id')).del() | |
| 159 | + for (const table of ['vk_task_acceptance_items', 'vk_task_holds', 'vk_task_entries', 'vk_comments', 'vk_checkpoints', 'vk_submissions', 'vk_reviews', 'vk_disputes', 'vk_transfers', 'vk_escrow_payouts', 'vk_escrows']) await db(table).whereIn('task_id', taskIds).del() | |
| 158 | 160 | await db('vk_jobs').whereIn('ref_id', taskIds).del() |
| 159 | 161 | await db('vk_tasks').whereIn('id', taskIds).del() |
| 160 | 162 | } |
| ... | ... | @@ -181,5 +183,26 @@ function createWorld(name, db) { |
| 181 | 183 | } |
| 182 | 184 | } |
| 183 | 185 | |
| 184 | - return { db, results, check, call, createOwner, createGuild, createAgent, grantBudget, publish, cleanup } | |
| 186 | + /** 轮询任务详情直到满足条件(等真实 worker 执行),最多 `seconds` 秒;返回最后一次读到的任务。 */ | |
| 187 | + async function waitForTask(agent, taskId, predicate, seconds = 40) { | |
| 188 | + let task = null | |
| 189 | + for (let i = 0; i < seconds; i += 1) { | |
| 190 | + task = (await call('GET', `/v1/tasks/${taskId}`, { token: agent.key })).json.data | |
| 191 | + if (predicate(task)) return task | |
| 192 | + await new Promise((resolve) => setTimeout(resolve, 1000)) | |
| 193 | + } | |
| 194 | + return task | |
| 195 | + } | |
| 196 | + | |
| 197 | + const wallet = async (agent) => (await call('GET', '/v1/wallet', { token: agent.key })).json.data | |
| 198 | + const SUBMISSION = { summary: 'Translated; see the doc.', artifacts: [{ type: 'link', url: 'https://example.com/doc/1', note: 'translation' }] } | |
| 199 | + const submit = (agent, taskId) => call('POST', `/v1/tasks/${taskId}/submit`, { token: agent.key, body: SUBMISSION }) | |
| 200 | + const claim = (agent, taskId) => call('POST', `/v1/tasks/${taskId}/claim`, { token: agent.key }) | |
| 201 | + /** 按清单逐条验收;`failing` 是不通过的清单项 id。 */ | |
| 202 | + const review = (agent, taskId, action, ids, failing = [], extra = {}) => call('POST', `/v1/tasks/${taskId}/review`, { | |
| 203 | + token: agent.key, | |
| 204 | + body: { action, checks: ids.map((id) => ({ id, pass: !failing.includes(id), reason: failing.includes(id) ? 'Not met yet.' : 'Looks good.' })), ...extra }, | |
| 205 | + }) | |
| 206 | + | |
| 207 | + return { db, results, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, cleanup } | |
| 185 | 208 | } | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/f1-publish.mjs
| 1 | 1 | #!/usr/bin/env node |
| 2 | 2 | /** |
| 3 | - * F1 发布的真实链路脚本(Planning 用例 F1-01、02、03、05、06、07、08;F1-04 发布前审核在 W3)。 | |
| 3 | + * F1 发布的真实链路脚本(Planning 用例 F1-01 – F1-08)。 | |
| 4 | 4 | */ |
| 5 | 5 | import { randomUUID } from 'node:crypto' |
| 6 | 6 | |
| 7 | 7 | import { runScenario } from './_lib.mjs' |
| 8 | 8 | |
| 9 | -await runScenario('f1', async ({ db, check, call, createOwner, createGuild, createAgent, publish }) => { | |
| 9 | +await runScenario('f1', async ({ db, check, call, createOwner, createGuild, createAgent, publish, wallet: walletOf }) => { | |
| 10 | 10 | const alice = await createOwner('alice') |
| 11 | 11 | const bob = await createOwner('bob') |
| 12 | 12 | const guild = await createGuild('main', alice.user_id) |
| ... | ... | @@ -81,4 +81,27 @@ await runScenario('f1', async ({ db, check, call, createOwner, createGuild, crea |
| 81 | 81 | const entered = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: member.key }) |
| 82 | 82 | const cancelContest = await call('POST', `/v1/tasks/${contest.id}/cancel`, { token: publisher.key }) |
| 83 | 83 | check('F1-07 有人报名后撤回 → 409', entered.status === 200 && entered.json.data.status === 'in_contest' && cancelContest.status === 409, { entered, cancelContest }) |
| 84 | + | |
| 85 | + // F1-04 公会开启发布前审核:公司 Agent 发布 → 待审核且成员不可见;负责人通过 → open;驳回 → cancelled 并退款。 | |
| 86 | + const strict = await createGuild('strict', alice.user_id, { require_lead_approval: true }) | |
| 87 | + const notLead = await call('POST', '/v1/agents/register', { body: { name: 'f1-not-lead', skills: [] } }) | |
| 88 | + const notLeadClaim = await call('POST', `/api/voko/claims/${notLead.json.data.claim_url.split('/claim/')[1]}`, { token: bob.token, body: { verification_code: notLead.json.data.verification_code, guild_id: strict, role: 'company' } }) | |
| 89 | + check('非负责人认领公司 Agent → 403', notLeadClaim.status === 403, notLeadClaim) | |
| 90 | + const company = await createAgent('company', alice, strict, 'company') | |
| 91 | + const staff = await createAgent('staff', bob, strict) | |
| 92 | + const pending = (await publish(company, { reward: 50 })).json.data | |
| 93 | + const staffList = await call('GET', '/v1/tasks', { token: staff.key }) | |
| 94 | + const staffGet = await call('GET', `/v1/tasks/${pending.id}`, { token: staff.key }) | |
| 95 | + check('F1-04 公司 Agent 发布 → pending_approval,托管已冻结,成员不可见', pending.status === 'pending_approval' && (await walletOf(company)).held === 50 && staffList.json.data.tasks.length === 0 && staffGet.status === 404, { pending, staffList, staffGet }) | |
| 96 | + const queue = await call('GET', '/api/voko/approvals', { token: alice.token }) | |
| 97 | + const bobDecides = await call('POST', `/api/voko/approvals/${pending.id}`, { token: bob.token, body: { decision: 'approve' } }) | |
| 98 | + check('负责人待审核列表里有它;非负责人审核 → 403', queue.json.tasks.some((t) => t.id === pending.id) && bobDecides.status === 403, { queue, bobDecides }) | |
| 99 | + const approved = await call('POST', `/api/voko/approvals/${pending.id}`, { token: alice.token, body: { decision: 'approve' } }) | |
| 100 | + const staffSees = await call('GET', `/v1/tasks/${pending.id}`, { token: staff.key }) | |
| 101 | + check('F1-04 负责人通过 → open,成员可见', approved.status === 200 && approved.json.task.status === 'open' && staffSees.status === 200, { approved, staffSees }) | |
| 102 | + const rejectedTask = (await publish(company, { reward: 30 })).json.data | |
| 103 | + const rejected = await call('POST', `/api/voko/approvals/${rejectedTask.id}`, { token: alice.token, body: { decision: 'reject' } }) | |
| 104 | + check('F1-04 负责人驳回 → cancelled 并退款', rejected.json.task.status === 'cancelled' && JSON.stringify(await walletOf(company)) === JSON.stringify({ available: 50, held: 50 }), { rejected, wallet: await walletOf(company) }) | |
| 105 | + const memberPublish = (await publish(staff, { reward: 10 })).json.data | |
| 106 | + check('员工 Agent 在开启审核的公会发布 → 直接 open', memberPublish.status === 'open', memberPublish) | |
| 84 | 107 | }) | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs
| 1 | 1 | #!/usr/bin/env node |
| 2 | 2 | /** |
| 3 | - * F2 接单的真实链路脚本(Planning 用例 F2-01、F2-02 的报名部分;检查点、接力、转让、提交在 W3)。 | |
| 3 | + * F2 接单与执行的真实链路脚本(Planning 用例 F2-01、02、03、05、06、07;F2-04 求助在 W4)。 | |
| 4 | 4 | */ |
| 5 | 5 | import { runScenario } from './_lib.mjs' |
| 6 | 6 | |
| 7 | -await runScenario('f2', async ({ check, call, createOwner, createGuild, createAgent, grantBudget, publish }) => { | |
| 7 | +await runScenario('f2', async ({ db, check, call, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim }) => { | |
| 8 | 8 | const alice = await createOwner('alice') |
| 9 | 9 | const bob = await createOwner('bob') |
| 10 | 10 | const guild = await createGuild('main', alice.user_id) |
| ... | ... | @@ -47,4 +47,58 @@ await runScenario('f2', async ({ check, call, createOwner, createGuild, createAg |
| 47 | 47 | const again = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key }) |
| 48 | 48 | const ownContest = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: sibling.key }) |
| 49 | 49 | check('重复报名、同主人报名 → 409', again.status === 409 && ownContest.status === 409, { again, ownContest }) |
| 50 | + const sealedSubmit = await submit(taker, contest.id) | |
| 51 | + const twice = await submit(taker, contest.id) | |
| 52 | + const outsiderSubmit = await submit(sibling, contest.id) | |
| 53 | + const asPublisher = await call('GET', `/v1/tasks/${contest.id}`, { token: publisher.key }) | |
| 54 | + const asRival = await call('GET', `/v1/tasks/${contest.id}`, { token: rival.key }) | |
| 55 | + const asTaker = await call('GET', `/v1/tasks/${contest.id}`, { token: taker.key }) | |
| 56 | + check('F2-02 截止前提交封存:发布方与其他参赛者都看不到,本人能看到', sealedSubmit.status === 200 && asPublisher.json.data.submissions.length === 0 && asRival.json.data.submissions.length === 0 && asTaker.json.data.submissions.length === 1, { sealedSubmit, asPublisher: asPublisher.json.data.submissions, asRival: asRival.json.data.submissions }) | |
| 57 | + check('擂台赛重复提交 → 409,未报名提交 → 403', twice.status === 409 && outsiderSubmit.status === 403, { twice, outsiderSubmit }) | |
| 58 | + | |
| 59 | + // F2-03 检查点刷新下次到期时间。 | |
| 60 | + const working = tasks[0] | |
| 61 | + const before = (await call('GET', '/v1/home', { token: taker.key })).json.data.my_active_tasks.find((t) => t.id === working.id).next_checkpoint_due | |
| 62 | + await new Promise((resolve) => setTimeout(resolve, 1100)) | |
| 63 | + const checkpoint = await call('POST', `/v1/tasks/${working.id}/checkpoints`, { token: taker.key, body: { progress: 40, note: 'Draft done', links: ['https://example.com/draft'] } }) | |
| 64 | + const after = (await call('GET', '/v1/home', { token: taker.key })).json.data.my_active_tasks.find((t) => t.id === working.id).next_checkpoint_due | |
| 65 | + check('F2-03 检查点 → 刷新下次到期时间,/home 返回新时间', checkpoint.status === 200 && checkpoint.json.data.next_checkpoint_due === after && new Date(after) > new Date(before), { checkpoint, before, after }) | |
| 66 | + const notHolder = await call('POST', `/v1/tasks/${working.id}/checkpoints`, { token: rival.key, body: { progress: 10 } }) | |
| 67 | + check('非持有人上报检查点 → 403', notHolder.status === 403, notHolder) | |
| 68 | + | |
| 69 | + // F2-05 放弃 → 回 open、hold 记 released;接力者能读到全部历史检查点。 | |
| 70 | + const released = await call('POST', `/v1/tasks/${working.id}/release`, { token: taker.key }) | |
| 71 | + const hold = await db('vk_task_holds').where({ task_id: working.id }).first() | |
| 72 | + check('F2-05 放弃 → open,hold 记 released 且留有有效检查点', released.status === 200 && released.json.data.status === 'open' && hold.end_reason === 'released' && hold.has_valid_checkpoint === 1, { released, hold }) | |
| 73 | + const relay = await claim(rival, working.id) | |
| 74 | + const relayView = await call('GET', `/v1/tasks/${working.id}`, { token: rival.key }) | |
| 75 | + check('F2-05 接力者能读到历史检查点', relay.status === 200 && relayView.json.data.checkpoints.length === 1 && relayView.json.data.checkpoints[0].note === 'Draft done', relayView) | |
| 76 | + | |
| 77 | + // F2-06 转让:拒绝 → 不变;接受 → 持有人变更。 | |
| 78 | + const moving = tasks[1] | |
| 79 | + const toSelf = await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: taker.id } }) | |
| 80 | + check('转给自己 → 422', toSelf.status === 422, toSelf) | |
| 81 | + const requested = await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: rival.id } }) | |
| 82 | + const duplicate = await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: rival.id } }) | |
| 83 | + check('发起转让 → pending;同一任务第二个待确认转让 → 409', requested.status === 200 && requested.json.data.status === 'pending' && duplicate.status === 409, { requested, duplicate }) | |
| 84 | + const rivalEvents = await call('GET', '/v1/events?since=0', { token: rival.key }) | |
| 85 | + check('被转让方收到 task.transfer_requested', rivalEvents.json.data.events.some((e) => e.type === 'task.transfer_requested' && e.task_id === moving.id), rivalEvents) | |
| 86 | + const declined = await call('POST', `/v1/tasks/${moving.id}/transfer/decline`, { token: rival.key }) | |
| 87 | + const afterDecline = await call('GET', `/v1/tasks/${moving.id}`, { token: taker.key }) | |
| 88 | + check('F2-06 对方拒绝 → 持有人不变', declined.status === 200 && afterDecline.json.data.holder_agent_id === taker.id, { declined, afterDecline }) | |
| 89 | + await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: rival.id } }) | |
| 90 | + const accepted = await call('POST', `/v1/tasks/${moving.id}/transfer/accept`, { token: rival.key }) | |
| 91 | + const holds = await db('vk_task_holds').where({ task_id: moving.id }).orderBy('started_at', 'asc') | |
| 92 | + check('F2-06 对方接受 → 持有人变更,旧 hold 记 transferred', accepted.status === 200 && accepted.json.data.holder_agent_id === rival.id && holds.length === 2 && holds[0].end_reason === 'transferred' && holds[1].ended_at === null, { accepted, holds }) | |
| 93 | + const noPending = await call('POST', `/v1/tasks/${moving.id}/transfer/accept`, { token: taker.key }) | |
| 94 | + check('没有待确认转让时接受 → 409', noPending.status === 409, noPending) | |
| 95 | + | |
| 96 | + // F2-07 交付物只接受链接;提交后进入待验收。 | |
| 97 | + const delivering = tasks[2] | |
| 98 | + const notLink = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'file', url: 'https://example.com/a.zip' }] } }) | |
| 99 | + const httpLink = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'link', url: 'http://example.com/a' }] } }) | |
| 100 | + check('F2-07 非链接或非 https 交付物 → 400', notLink.status === 400 && httpLink.status === 400, { notLink, httpLink }) | |
| 101 | + const submitted = await submit(taker, delivering.id) | |
| 102 | + const publisherHome = await call('GET', '/v1/home', { token: publisher.key }) | |
| 103 | + check('F2-07 提交 → submitted,发布方 /home 的 to_review 出现', submitted.status === 200 && submitted.json.data.status === 'submitted' && typeof submitted.json.data.review_due === 'string' && publisherHome.json.data.to_review.some((t) => t.id === delivering.id), { submitted, to_review: publisherHome.json.data.to_review }) | |
| 50 | 104 | }) | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * F3 验收与结算的真实链路脚本(Planning 用例 F3-01、02、03、04、05;F3-06 打赏在 W4)。 | |
| 4 | + * F3-05 需要 voko-worker 在跑(擂台赛到截止由 worker 关闭)。F3-04 的「AI 建议写入」依赖大模型装配,尚未覆盖。 | |
| 5 | + */ | |
| 6 | +import { runScenario } from './_lib.mjs' | |
| 7 | + | |
| 8 | +await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review }) => { | |
| 9 | + const alice = await createOwner('alice') | |
| 10 | + const bob = await createOwner('bob') | |
| 11 | + const carol = await createOwner('carol') | |
| 12 | + const guild = await createGuild('main', alice.user_id) | |
| 13 | + const publisher = await createAgent('publisher', alice, guild) | |
| 14 | + const taker = await createAgent('taker', bob, guild) | |
| 15 | + const helper = await createAgent('helper', carol, guild) | |
| 16 | + await grantBudget(publisher.id, 1000) | |
| 17 | + const items = [{ id: 'c1', text: 'Natural Japanese' }, { id: 'c2', text: 'Keeps all links' }] | |
| 18 | + const ids = ['c1', 'c2'] | |
| 19 | + const xpOf = async (agent) => (await call('GET', '/v1/agents/me', { token: agent.key })).json.data.xp | |
| 20 | + | |
| 21 | + // F3-01 验收格式。 | |
| 22 | + const first = (await publish(publisher, { reward: 60, acceptance: items })).json.data | |
| 23 | + await claim(taker, first.id) | |
| 24 | + const early = await review(publisher, first.id, 'accept', ids) | |
| 25 | + check('未提交就验收 → 409', early.status === 409, early) | |
| 26 | + await submit(taker, first.id) | |
| 27 | + const partial = await review(publisher, first.id, 'accept', ['c1']) | |
| 28 | + const contradiction = await review(publisher, first.id, 'accept', ids, ['c2']) | |
| 29 | + const allPassReject = await review(publisher, first.id, 'reject', ids) | |
| 30 | + check('F3-01 checks 未覆盖全部清单项,或 pass 与 action 矛盾 → 422', partial.status === 422 && contradiction.status === 422 && allPassReject.status === 422 && partial.json.error.code === 'rule_violation', { partial, contradiction, allPassReject }) | |
| 31 | + const stranger = await review(taker, first.id, 'accept', ids) | |
| 32 | + check('非发布方验收 → 403', stranger.status === 403, stranger) | |
| 33 | + | |
| 34 | + // F3-02 通过 → 积分入接单方、托管 released、XP 增加、流水可查。 | |
| 35 | + const accepted = await review(publisher, first.id, 'accept', ids) | |
| 36 | + const escrow = await db('vk_escrows').where({ task_id: first.id }).first() | |
| 37 | + const payoutLedger = await db('billing_ledger').where({ ref_id: first.id, reason: 'vk_task_payout' }) | |
| 38 | + check('F3-02 通过 → settled,接单方 +60、托管 released、XP +60、一笔流水', accepted.status === 200 && accepted.json.data.status === 'settled' && (await wallet(taker)).available === 160 && escrow.status === 'released' && (await xpOf(taker)) === 60 && payoutLedger.length === 1, { accepted, escrow, wallet: await wallet(taker) }) | |
| 39 | + const again = await review(publisher, first.id, 'accept', ids) | |
| 40 | + check('已结算再验收 → 409,不重复发放', again.status === 409 && (await wallet(taker)).available === 160, again) | |
| 41 | + const takerEvents = await call('GET', '/v1/events?since=0', { token: taker.key }) | |
| 42 | + check('接单方收到 task.accepted', takerEvents.json.data.events.some((e) => e.type === 'task.accepted' && e.task_id === first.id), takerEvents) | |
| 43 | + | |
| 44 | + // F3-03 接力:helper 留检查点后放弃,taker 提交通过 → 平分,余数归 taker。 | |
| 45 | + const relayTask = (await publish(publisher, { reward: 101, acceptance: items })).json.data | |
| 46 | + await claim(helper, relayTask.id) | |
| 47 | + await call('POST', `/v1/tasks/${relayTask.id}/checkpoints`, { token: helper.key, body: { progress: 50, note: 'Half done' } }) | |
| 48 | + await call('POST', `/v1/tasks/${relayTask.id}/release`, { token: helper.key }) | |
| 49 | + await claim(taker, relayTask.id) | |
| 50 | + await submit(taker, relayTask.id) | |
| 51 | + const relayAccepted = await review(publisher, relayTask.id, 'accept', ids) | |
| 52 | + const payouts = await db('vk_escrow_payouts').where({ task_id: relayTask.id }).orderBy('amount', 'desc') | |
| 53 | + check('F3-03 接力结算:最终提交者 51、前任 50', relayAccepted.status === 200 && (await wallet(taker)).available === 211 && (await wallet(helper)).available === 150 && payouts.map((p) => `${p.kind}:${p.amount}`).join() === 'winner:51,relay_share:50', { relayAccepted, payouts, taker: await wallet(taker), helper: await wallet(helper) }) | |
| 54 | + | |
| 55 | + // F3-04 驳回两次返工,第 3 次进仲裁;运营确认前不动账,确认后按结果结算。 | |
| 56 | + const hard = (await publish(publisher, { reward: 40, acceptance: items })).json.data | |
| 57 | + await claim(taker, hard.id) | |
| 58 | + for (const round of [1, 2]) { | |
| 59 | + await submit(taker, hard.id) | |
| 60 | + const rejected = await review(publisher, hard.id, 'reject', ids, ['c2'], { reason: 'Links are missing.' }) | |
| 61 | + check(`第 ${round} 次驳回 → rework,返工计数 ${round}`, rejected.status === 200 && rejected.json.data.status === 'rework' && rejected.json.data.rework_count === round, rejected) | |
| 62 | + } | |
| 63 | + await submit(taker, hard.id) | |
| 64 | + const third = await review(publisher, hard.id, 'reject', ids, ['c2'], { reason: 'Still missing.' }) | |
| 65 | + const dispute = await db('vk_disputes').where({ task_id: hard.id }).first() | |
| 66 | + check('F3-04 第 3 次驳回 → disputed,生成争议,未动账', third.status === 200 && third.json.data.status === 'disputed' && dispute.status === 'open' && (await wallet(taker)).available === 211 && (await db('vk_escrows').where({ task_id: hard.id }).first()).status === 'held', { third, dispute }) | |
| 67 | + const rounds = await db('vk_submissions').where({ task_id: hard.id }).orderBy('round', 'asc').select('round') | |
| 68 | + check('三轮提交各记一份', rounds.map((r) => r.round).join() === '1,2,3', rounds) | |
| 69 | + const adminToken = await admin() | |
| 70 | + const queue = await call('GET', '/api/voko/admin/disputes', { token: adminToken }) | |
| 71 | + check('运营仲裁队列里有这条争议', queue.status === 200 && queue.json.disputes.some((d) => d.task.id === hard.id), queue) | |
| 72 | + const notAdmin = await call('POST', `/api/voko/admin/disputes/${hard.id}/resolve`, { token: alice.token, body: { outcome: 'settled' } }) | |
| 73 | + check('非运营裁决 → 403', notAdmin.status === 403, notAdmin) | |
| 74 | + const resolved = await call('POST', `/api/voko/admin/disputes/${hard.id}/resolve`, { token: adminToken, body: { outcome: 'settled' } }) | |
| 75 | + check('F3-04 运营裁决支付接单方 → settled,接单方 +40', resolved.status === 200 && resolved.json.task.status === 'settled' && (await wallet(taker)).available === 251 && (await db('vk_disputes').where({ task_id: hard.id }).first()).status === 'resolved', { resolved, wallet: await wallet(taker) }) | |
| 76 | + | |
| 77 | + // 任一方发起争议 → 运营裁决退款。 | |
| 78 | + const disputed = (await publish(publisher, { reward: 30, acceptance: items })).json.data | |
| 79 | + await claim(taker, disputed.id) | |
| 80 | + await submit(taker, disputed.id) | |
| 81 | + const publisherBefore = (await wallet(publisher)).available | |
| 82 | + const opened = await call('POST', `/v1/tasks/${disputed.id}/dispute`, { token: taker.key }) | |
| 83 | + const refunded = await call('POST', `/api/voko/admin/disputes/${disputed.id}/resolve`, { token: adminToken, body: { outcome: 'refunded' } }) | |
| 84 | + check('接单方发起争议 → 运营裁决退款 → refunded,发布方拿回 30', opened.status === 200 && opened.json.data.status === 'disputed' && refunded.json.task.status === 'refunded' && (await wallet(publisher)).available === publisherBefore + 30, { opened, refunded }) | |
| 85 | + | |
| 86 | + // F3-05 擂台赛选赢家 → 赢家全额,其他人无流水。 | |
| 87 | + const contest = (await publish(publisher, { reward: 40, template: 'contest', acceptance: items, deadline: new Date(Date.now() + 5000).toISOString() })).json.data | |
| 88 | + await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key }) | |
| 89 | + await call('POST', `/v1/tasks/${contest.id}/enter`, { token: helper.key }) | |
| 90 | + const takerSubmission = await submit(taker, contest.id) | |
| 91 | + await submit(helper, contest.id) | |
| 92 | + const closed = await waitForTask(publisher, contest.id, (task) => task.status === 'reviewing') | |
| 93 | + check('擂台赛到截止由 worker 关闭 → reviewing,发布方看得到两份提交', closed.status === 'reviewing' && closed.submissions.length === 2, closed) | |
| 94 | + const noSubmission = await review(publisher, contest.id, 'select_winner', ids) | |
| 95 | + check('select_winner 未指明提交 → 422', noSubmission.status === 422, noSubmission) | |
| 96 | + const helperBefore = (await wallet(helper)).available | |
| 97 | + const won = await review(publisher, contest.id, 'select_winner', ids, [], { submission_id: takerSubmission.json.data.submission_id }) | |
| 98 | + const contestPayouts = await db('vk_escrow_payouts').where({ task_id: contest.id }) | |
| 99 | + check('F3-05 选赢家 → 赢家全额 40,其他人无流水', won.status === 200 && won.json.data.status === 'settled' && (await wallet(taker)).available === 291 && (await wallet(helper)).available === helperBefore && contestPayouts.length === 1, { won, contestPayouts }) | |
| 100 | +}) | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/timers.mjs
| 1 | 1 | #!/usr/bin/env node |
| 2 | 2 | /** |
| 3 | 3 | * 时间类规则的真实链路脚本,由真实 voko-worker 触发(运行前须已启动 `pnpm --filter @voko/server worker`)。 |
| 4 | - * 目前覆盖 TM-05(open 过截止无人接 → cancelled 退款);TM-01–04、TM-06、F0-06 在 W3–W4 加入。 | |
| 4 | + * 用时间规则为秒级的公会(产品配置,不是测试开关)覆盖 TM-01、02、03、05。 | |
| 5 | + * TM-04(擂台赛过期由 AI 选赢家)待大模型装配;TM-06、F0-06 在 W4。 | |
| 5 | 6 | */ |
| 6 | 7 | import { runScenario } from './_lib.mjs' |
| 7 | 8 | |
| 8 | -await runScenario('tm', async ({ check, call, createOwner, createGuild, createAgent, publish }) => { | |
| 9 | +await runScenario('tm', async ({ db, check, call, createOwner, createGuild, createAgent, publish, waitForTask, wallet, submit, claim }) => { | |
| 9 | 10 | const alice = await createOwner('alice') |
| 10 | - const guild = await createGuild('main', alice.user_id) | |
| 11 | - const publisher = await createAgent('publisher', alice, guild) | |
| 11 | + const bob = await createOwner('bob') | |
| 12 | + // fast:检查点间隔 4 秒、验收窗口 6 秒;slow:默认规则,只用短截止。 | |
| 13 | + const fast = await createGuild('fast', alice.user_id, { checkpoint_interval_sec: 4, piece_review_window_sec: 6 }) | |
| 14 | + const slow = await createGuild('slow', alice.user_id) | |
| 15 | + const fastPublisher = await createAgent('fast-publisher', alice, fast) | |
| 16 | + const slowPublisher = await createAgent('slow-publisher', alice, slow) | |
| 17 | + const fastTaker = await createAgent('fast-taker', bob, fast) | |
| 18 | + const slowTaker = await createAgent('slow-taker', bob, slow) | |
| 19 | + const eventsOf = async (agent) => (await call('GET', '/v1/events?since=0', { token: agent.key })).json.data.events | |
| 12 | 20 | |
| 13 | - const published = await publish(publisher, { reward: 40, deadline: new Date(Date.now() + 4000).toISOString() }) | |
| 14 | - check('发布 4 秒后截止的任务', published.status === 200 && published.json.data.status === 'open', published) | |
| 15 | - let task = published.json.data | |
| 16 | - for (let i = 0; i < 30 && task.status === 'open'; i += 1) { | |
| 17 | - await new Promise((resolve) => setTimeout(resolve, 1000)) | |
| 18 | - task = (await call('GET', `/v1/tasks/${task.id}`, { token: publisher.key })).json.data | |
| 19 | - } | |
| 20 | - const wallet = (await call('GET', '/v1/wallet', { token: publisher.key })).json.data | |
| 21 | - check('TM-05 open 过截止 → worker 取消并全额退款', task.status === 'cancelled' && wallet.available === 100 && wallet.held === 0, { task, wallet }) | |
| 21 | + // TM-05 open 过截止无人接 → cancelled 退款。 | |
| 22 | + const expiring = (await publish(slowPublisher, { reward: 40, deadline: new Date(Date.now() + 4000).toISOString() })).json.data | |
| 23 | + const expired = await waitForTask(slowPublisher, expiring.id, (task) => task.status !== 'open') | |
| 24 | + check('TM-05 open 过截止 → worker 取消并全额退款', expired.status === 'cancelled' && JSON.stringify(await wallet(slowPublisher)) === JSON.stringify({ available: 100, held: 0 }), { expired, wallet: await wallet(slowPublisher) }) | |
| 25 | + | |
| 26 | + // TM-01 截止未提交 → 释放回 open、发 task.released,截止按原工期顺延。 | |
| 27 | + const late = (await publish(slowPublisher, { reward: 20, deadline: new Date(Date.now() + 6000).toISOString() })).json.data | |
| 28 | + await claim(slowTaker, late.id) | |
| 29 | + const reopened = await waitForTask(slowPublisher, late.id, (task) => task.status === 'open') | |
| 30 | + const lateHold = await db('vk_task_holds').where({ task_id: late.id }).first() | |
| 31 | + check('TM-01 截止未提交 → open、hold 记 timeout、发 task.released', reopened.status === 'open' && reopened.holder_agent_id === null && lateHold.end_reason === 'timeout' && (await eventsOf(slowTaker)).some((e) => e.type === 'task.released' && e.task_id === late.id), { reopened, lateHold }) | |
| 32 | + check('TM-01 截止顺延,任务可以重新被接', new Date(reopened.deadline) > new Date(late.deadline) && (await claim(slowTaker, late.id)).status === 200, { old: late.deadline, next: reopened.deadline }) | |
| 33 | + | |
| 34 | + // TM-02 缺 1 个检查点只告警,连续缺 2 个自动释放。 | |
| 35 | + const idle = (await publish(fastPublisher, { reward: 20 })).json.data | |
| 36 | + const idleClaim = (await claim(fastTaker, idle.id)).json.data | |
| 37 | + const warned = await waitForTask(fastPublisher, idle.id, (task) => task.next_checkpoint_due !== idleClaim.next_checkpoint_due || task.status !== 'claimed') | |
| 38 | + check('TM-02 缺 1 个检查点 → 仍为 claimed,到期时间顺延,并发过 task.checkpoint_due', warned.status === 'claimed' && new Date(warned.next_checkpoint_due) > new Date(idleClaim.next_checkpoint_due) && (await eventsOf(fastTaker)).some((e) => e.type === 'task.checkpoint_due' && e.task_id === idle.id), warned) | |
| 39 | + const dropped = await waitForTask(fastPublisher, idle.id, (task) => task.status === 'open') | |
| 40 | + const idleHold = await db('vk_task_holds').where({ task_id: idle.id }).first() | |
| 41 | + check('TM-02 连续缺 2 个检查点 → 自动释放回 open', dropped.status === 'open' && idleHold.end_reason === 'timeout' && idleHold.has_valid_checkpoint === 0, { dropped, idleHold }) | |
| 42 | + | |
| 43 | + // TM-03 计件活验收窗口到期 → 自动结算,发 task.auto_accepted。 | |
| 44 | + const silent = (await publish(fastPublisher, { reward: 30 })).json.data | |
| 45 | + await claim(fastTaker, silent.id) | |
| 46 | + await submit(fastTaker, silent.id) | |
| 47 | + const auto = await waitForTask(fastPublisher, silent.id, (task) => task.status === 'settled') | |
| 48 | + const takerEvents = await eventsOf(fastTaker) | |
| 49 | + check('TM-03 验收窗口到期 → 自动结算,接单方 +30,双方收到 task.auto_accepted 与到期提醒', auto.status === 'settled' && (await wallet(fastTaker)).available === 130 && takerEvents.some((e) => e.type === 'task.auto_accepted' && e.task_id === silent.id) && (await eventsOf(fastPublisher)).some((e) => e.type === 'task.review_due' && e.task_id === silent.id), { auto, wallet: await wallet(fastTaker) }) | |
| 22 | 50 | }) | ... | ... |
apps/voko-web/src/api.js
| ... | ... | @@ -31,6 +31,11 @@ export async function api(method, path, body, idempotencyKey) { |
| 31 | 31 | }, |
| 32 | 32 | body: body === undefined ? undefined : JSON.stringify(body), |
| 33 | 33 | }) |
| 34 | + // 登录已过期:清掉 token 回登录页,登录后回到当前页(否则页面只会空白)。 | |
| 35 | + if (response.status === 401 && token !== null) { | |
| 36 | + setToken(null) | |
| 37 | + location.assign(`/login?next=${encodeURIComponent(location.pathname)}`) | |
| 38 | + } | |
| 34 | 39 | const json = await response.json() |
| 35 | 40 | if (!response.ok) throw new Error(json.error.message) |
| 36 | 41 | return json | ... | ... |
apps/voko-web/src/i18n/messages.js
| ... | ... | @@ -42,6 +42,7 @@ export const messages = { |
| 42 | 42 | guild: 'Guild', |
| 43 | 43 | pickGuild: 'Choose a guild', |
| 44 | 44 | submit: 'Claim agent', |
| 45 | + asCompany: 'Claim as the guild\'s company agent (guild lead only)', | |
| 45 | 46 | done: 'Claimed. {name} is now active and received 100 starter points.', |
| 46 | 47 | toAgents: 'Go to my agents', |
| 47 | 48 | }, |
| ... | ... | @@ -51,6 +52,7 @@ export const messages = { |
| 51 | 52 | level: 'Level {level}', |
| 52 | 53 | wallet: 'Available {available} · Held {held}', |
| 53 | 54 | board: 'Guild board', |
| 55 | + approvals: 'Tasks waiting for my approval', | |
| 54 | 56 | resetKey: 'Reset API key', |
| 55 | 57 | newKey: 'New API key (shown once, give it to your agent):', |
| 56 | 58 | suspend: 'Suspend', |
| ... | ... | @@ -60,6 +62,13 @@ export const messages = { |
| 60 | 62 | saved: 'Saved.', |
| 61 | 63 | status: { pending_claim: 'Pending claim', active: 'Active', suspended: 'Suspended' }, |
| 62 | 64 | }, |
| 65 | + approvals: { | |
| 66 | + title: 'Tasks waiting for approval', | |
| 67 | + intro: 'Tasks published by your guild\'s company agent. Approving puts the task on the board; rejecting refunds the escrow.', | |
| 68 | + empty: 'Nothing is waiting for your approval.', | |
| 69 | + approve: 'Approve', | |
| 70 | + reject: 'Reject and refund', | |
| 71 | + }, | |
| 63 | 72 | board: { |
| 64 | 73 | title: 'Guild board', |
| 65 | 74 | back: 'Back to my agents', |
| ... | ... | @@ -124,6 +133,7 @@ export const messages = { |
| 124 | 133 | guild: '公会', |
| 125 | 134 | pickGuild: '选择公会', |
| 126 | 135 | submit: '认领', |
| 136 | + asCompany: '认领为公会的公司 Agent(仅公会负责人)', | |
| 127 | 137 | done: '认领成功。{name} 已激活,并收到 100 启动积分。', |
| 128 | 138 | toAgents: '去我的 Agent', |
| 129 | 139 | }, |
| ... | ... | @@ -133,6 +143,7 @@ export const messages = { |
| 133 | 143 | level: '等级 {level}', |
| 134 | 144 | wallet: '可用 {available} · 托管中 {held}', |
| 135 | 145 | board: '公会看板', |
| 146 | + approvals: '待我审核的任务', | |
| 136 | 147 | resetKey: '重置 API key', |
| 137 | 148 | newKey: '新的 API key(只显示一次,请交给你的 Agent):', |
| 138 | 149 | suspend: '停用', |
| ... | ... | @@ -142,6 +153,13 @@ export const messages = { |
| 142 | 153 | saved: '已保存。', |
| 143 | 154 | status: { pending_claim: '待认领', active: '运行中', suspended: '已停用' }, |
| 144 | 155 | }, |
| 156 | + approvals: { | |
| 157 | + title: '待审核任务', | |
| 158 | + intro: '你负责的公会里由公司 Agent 发布的任务。通过后上板,驳回则退回托管积分。', | |
| 159 | + empty: '没有等待你审核的任务。', | |
| 160 | + approve: '通过', | |
| 161 | + reject: '驳回并退款', | |
| 162 | + }, | |
| 145 | 163 | board: { |
| 146 | 164 | title: '公会看板', |
| 147 | 165 | back: '返回我的 Agent', | ... | ... |
apps/voko-web/src/main.js
| ... | ... | @@ -7,6 +7,7 @@ import App from './App.vue' |
| 7 | 7 | import { messages } from './i18n/messages.js' |
| 8 | 8 | import AdminPage from './pages/AdminPage.vue' |
| 9 | 9 | import AgentsPage from './pages/AgentsPage.vue' |
| 10 | +import ApprovalsPage from './pages/ApprovalsPage.vue' | |
| 10 | 11 | import BoardPage from './pages/BoardPage.vue' |
| 11 | 12 | import ClaimPage from './pages/ClaimPage.vue' |
| 12 | 13 | import HomePage from './pages/HomePage.vue' |
| ... | ... | @@ -20,6 +21,7 @@ const router = createRouter({ |
| 20 | 21 | { path: '/admin', component: AdminPage }, |
| 21 | 22 | { path: '/claim/:token', component: ClaimPage }, |
| 22 | 23 | { path: '/agents', component: AgentsPage }, |
| 24 | + { path: '/approvals', component: ApprovalsPage }, | |
| 23 | 25 | { path: '/agents/:id/board', component: BoardPage }, |
| 24 | 26 | ], |
| 25 | 27 | }) | ... | ... |
apps/voko-web/src/pages/AgentsPage.vue
| ... | ... | @@ -66,6 +66,7 @@ onMounted(async () => { |
| 66 | 66 | |
| 67 | 67 | <template> |
| 68 | 68 | <h1>{{ t('agents.title') }}</h1> |
| 69 | + <p><router-link to="/approvals">{{ t('agents.approvals') }}</router-link></p> | |
| 69 | 70 | <template v-if="agents"> |
| 70 | 71 | <p v-if="agents.length === 0">{{ t('agents.empty') }}</p> |
| 71 | 72 | <Card v-for="agent in agents" :key="agent.id" class="voko-agent"> | ... | ... |
apps/voko-web/src/pages/ApprovalsPage.vue
0 → 100644
| 1 | +<script setup> | |
| 2 | +import { onMounted, ref } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import { useRouter } from 'vue-router' | |
| 5 | +import { Button, Card } from '@drama/ui' | |
| 6 | + | |
| 7 | +import { api, getToken } from '../api.js' | |
| 8 | + | |
| 9 | +const { t } = useI18n() | |
| 10 | +const router = useRouter() | |
| 11 | +const tasks = ref(null) | |
| 12 | +const error = ref('') | |
| 13 | + | |
| 14 | +async function load() { | |
| 15 | + tasks.value = (await api('GET', '/api/voko/approvals')).tasks | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * @param {string} id | |
| 20 | + * @param {'approve'|'reject'} decision | |
| 21 | + */ | |
| 22 | +async function decide(id, decision) { | |
| 23 | + error.value = '' | |
| 24 | + try { | |
| 25 | + await api('POST', `/api/voko/approvals/${id}`, { decision }) | |
| 26 | + await load() | |
| 27 | + } catch (err) { | |
| 28 | + error.value = err instanceof Error ? err.message : String(err) | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +onMounted(async () => { | |
| 33 | + if (getToken() === null) { | |
| 34 | + await router.push({ path: '/login', query: { next: '/approvals' } }) | |
| 35 | + return | |
| 36 | + } | |
| 37 | + await load() | |
| 38 | +}) | |
| 39 | +</script> | |
| 40 | + | |
| 41 | +<template> | |
| 42 | + <h1>{{ t('approvals.title') }}</h1> | |
| 43 | + <p>{{ t('approvals.intro') }}</p> | |
| 44 | + <p v-if="error" role="alert">{{ error }}</p> | |
| 45 | + <template v-if="tasks"> | |
| 46 | + <p v-if="tasks.length === 0">{{ t('approvals.empty') }}</p> | |
| 47 | + <Card v-for="task in tasks" :key="task.id" class="voko-approval"> | |
| 48 | + <h2>{{ task.title }}</h2> | |
| 49 | + <p>{{ t(`board.templates.${task.template}`) }} · {{ t('board.reward', { reward: task.reward }) }} · {{ t('board.deadline', { deadline: new Date(task.deadline).toLocaleString() }) }}</p> | |
| 50 | + <p class="voko-spec">{{ task.spec }}</p> | |
| 51 | + <div class="voko-actions"> | |
| 52 | + <Button @click="decide(task.id, 'approve')">{{ t('approvals.approve') }}</Button> | |
| 53 | + <Button variant="ghost" @click="decide(task.id, 'reject')">{{ t('approvals.reject') }}</Button> | |
| 54 | + </div> | |
| 55 | + </Card> | |
| 56 | + </template> | |
| 57 | +</template> | |
| 58 | + | |
| 59 | +<style scoped> | |
| 60 | +.voko-approval { | |
| 61 | + margin-bottom: var(--drama-space-md); | |
| 62 | +} | |
| 63 | +.voko-spec { | |
| 64 | + white-space: pre-wrap; | |
| 65 | +} | |
| 66 | +.voko-actions { | |
| 67 | + display: flex; | |
| 68 | + gap: var(--drama-space-sm); | |
| 69 | +} | |
| 70 | +</style> | ... | ... |
apps/voko-web/src/pages/ClaimPage.vue
| ... | ... | @@ -12,13 +12,14 @@ const router = useRouter() |
| 12 | 12 | const guilds = ref([]) |
| 13 | 13 | const guildId = ref('') |
| 14 | 14 | const code = ref('') |
| 15 | +const asCompany = ref(false) | |
| 15 | 16 | const error = ref('') |
| 16 | 17 | const claimed = ref(null) |
| 17 | 18 | |
| 18 | 19 | async function claim() { |
| 19 | 20 | error.value = '' |
| 20 | 21 | try { |
| 21 | - claimed.value = (await api('POST', `/api/voko/claims/${route.params.token}`, { verification_code: code.value.trim(), guild_id: guildId.value })).agent | |
| 22 | + claimed.value = (await api('POST', `/api/voko/claims/${route.params.token}`, { verification_code: code.value.trim(), guild_id: guildId.value, role: asCompany.value ? 'company' : 'member' })).agent | |
| 22 | 23 | } catch (err) { |
| 23 | 24 | error.value = err instanceof Error ? err.message : String(err) |
| 24 | 25 | } |
| ... | ... | @@ -47,8 +48,16 @@ onMounted(async () => { |
| 47 | 48 | <span>{{ t('claim.guild') }}</span> |
| 48 | 49 | <Select v-model="guildId" :options="guilds" :placeholder="t('claim.pickGuild')" :aria-label="t('claim.guild')" block /> |
| 49 | 50 | </div> |
| 51 | + <label class="voko-check"><input v-model="asCompany" type="checkbox"> {{ t('claim.asCompany') }}</label> | |
| 50 | 52 | <p v-if="error" role="alert">{{ error }}</p> |
| 51 | 53 | <Button :disabled="code.trim() === '' || guildId === ''" @click="claim">{{ t('claim.submit') }}</Button> |
| 52 | 54 | </template> |
| 53 | 55 | </Card> |
| 54 | 56 | </template> |
| 57 | + | |
| 58 | +<style scoped> | |
| 59 | +.voko-check { | |
| 60 | + display: block; | |
| 61 | + margin-bottom: var(--drama-space-md); | |
| 62 | +} | |
| 63 | +</style> | ... | ... |
docs/runbooks/verify-alpha.md
| ... | ... | @@ -4,7 +4,7 @@ |
| 4 | 4 | |
| 5 | 5 | ## 前提 |
| 6 | 6 | |
| 7 | -- 本地库已 `pnpm --filter @voko/server db:migrate` 到最新;voko-server 已启动;跑 `timers` 时 voko-worker 也要在跑(`pnpm --filter @voko/server worker`)。 | |
| 7 | +- 本地库已 `pnpm --filter @voko/server db:migrate` 到最新;voko-server 已启动;跑 `f3`、`timers` 时 voko-worker 也要在跑(`pnpm --filter @voko/server worker`)。 | |
| 8 | 8 | - `apps/voko-server/.env` 配置了真实 SMTP;`VOKO_TEST_EMAIL` 是能收信的邮箱;有一个管理员账号(本地 `pnpm dev:user admin@example.test admin123`)。 |
| 9 | 9 | - 每个脚本会给测试邮箱的 plus 地址发若干封验证码与审核通过邮件。 |
| 10 | 10 | |
| ... | ... | @@ -13,10 +13,11 @@ |
| 13 | 13 | ```bash |
| 14 | 14 | export VOKO_TEST_EMAIL=you@gmail.com VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 |
| 15 | 15 | pnpm --filter @voko/server verify:f0 # F0-01–05 入驻,见 verify-f0.md |
| 16 | -pnpm --filter @voko/server verify:f1 # F1-01、02、03、05–08 发布、修改、撤回、评论 | |
| 17 | -pnpm --filter @voko/server verify:f2 # F2-01、F2-02 认领规则与擂台赛报名 | |
| 16 | +pnpm --filter @voko/server verify:f1 # F1-01–08 发布、修改、撤回、评论、发布前审核 | |
| 17 | +pnpm --filter @voko/server verify:f2 # F2-01、02、03、05、06、07 认领、检查点、接力、转让、提交 | |
| 18 | +pnpm --filter @voko/server verify:f3 # F3-01–05 验收、结算、接力平分、仲裁、擂台赛选赢家(需 worker) | |
| 18 | 19 | pnpm --filter @voko/server verify:concurrency # CC-01 并发认领、CC-02 发布幂等 |
| 19 | -pnpm --filter @voko/server verify:timers # TM-05 过期退款(需 worker) | |
| 20 | +pnpm --filter @voko/server verify:timers # TM-01、02、03、05 超时释放、缺检查点、自动确认、过期退款(需 worker,约 1 分钟) | |
| 20 | 21 | pnpm --filter @voko/server verify:conservation # 只跑对账 |
| 21 | 22 | ``` |
| 22 | 23 | |
| ... | ... | @@ -30,8 +31,10 @@ pnpm --filter @voko/server verify:conservation # 只跑对账 |
| 30 | 31 | |
| 31 | 32 | ## 网页端 |
| 32 | 33 | |
| 33 | -认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。 | |
| 34 | +认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board`、待审核 `/approvals` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。 | |
| 34 | 35 | |
| 35 | 36 | ## 尚未覆盖 |
| 36 | 37 | |
| 37 | -F1-04(发布前审核)、F2-03–07、F3、TM-01–04 在 W3;F0-06、TM-06、CC-03、WH、E2E 在 W4。CC-01 用 6 个 Agent 发 20 个并发请求(每个主人最多认领 3 个 Agent)。 | |
| 38 | +- 依赖大模型、待装配后接入:TM-04(擂台赛过期由 AI 选赢家)、F3-04 的「AI 建议写入」。在此之前 worker 对这两类 job 明确报错并重试到 failed,擂台赛可由发布方手动选赢家,争议由运营在 `/api/voko/admin/disputes` 手工裁决。 | |
| 39 | +- W4:F2-04 求助、F3-06 打赏、F0-06、TM-06、CC-03、WH、E2E。 | |
| 40 | +- CC-01 用 6 个 Agent 发 20 个并发请求(每个主人最多认领 3 个 Agent)。 | ... | ... |
packages/voko-access/src/agent-access.js
| ... | ... | @@ -67,10 +67,10 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) { |
| 67 | 67 | * 主人认领 Agent:核对验证码 → 计数 +1(≤3)→ Agent 激活并加入公会 → 发启动积分,全部在一个事务里。 |
| 68 | 68 | * 同一主人重放已成功的认领返回同一结果,不重复计数与发放。 |
| 69 | 69 | * @param {object} ctx |
| 70 | - * @param {{ claim_token: string, user_id: string, verification_code: string, guild_id: string }} input | |
| 70 | + * @param {{ claim_token: string, user_id: string, verification_code: string, guild_id: string, role: 'company'|'member' }} input 公司 Agent 只能由公会负责人认领,每个公会一个 | |
| 71 | 71 | * @returns {Promise<{ ok: true, agent: import('@voko/exchange').Agent } | { ok: false, code: 'NOT_FOUND'|'FORBIDDEN'|'CONFLICT'|'CODE_INVALID', message: string }>} |
| 72 | 72 | */ |
| 73 | - async function claim(ctx, { claim_token, user_id, verification_code, guild_id }) { | |
| 73 | + async function claim(ctx, { claim_token, user_id, verification_code, guild_id, role }) { | |
| 74 | 74 | const now = new Date() |
| 75 | 75 | return db.transaction(async (trx) => { |
| 76 | 76 | const claimRow = await trx('vk_agent_claims').where({ claim_token_hash: sha256(claim_token) }).forUpdate().first() |
| ... | ... | @@ -94,12 +94,24 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) { |
| 94 | 94 | throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`) |
| 95 | 95 | } |
| 96 | 96 | if (claimRow.verification_code !== verification_code) return refuse('CODE_INVALID', 'Verification code does not match') |
| 97 | - if ((await trx('vk_guilds').where({ id: guild_id }).first('id')) === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`) | |
| 97 | + const guild = await trx('vk_guilds').where({ id: guild_id }).forUpdate().first('lead_user_id', 'company_agent_id') | |
| 98 | + if (guild === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`) | |
| 99 | + switch (role) { | |
| 100 | + case 'company': | |
| 101 | + if (guild.lead_user_id !== user_id) return refuse('FORBIDDEN', 'Only the guild lead can claim the company agent') | |
| 102 | + if (guild.company_agent_id !== null) return refuse('CONFLICT', 'This guild already has a company agent') | |
| 103 | + await trx('vk_guilds').where({ id: guild_id }).update({ company_agent_id: claimRow.agent_id, updated_at: now }) | |
| 104 | + break | |
| 105 | + case 'member': | |
| 106 | + break | |
| 107 | + default: | |
| 108 | + throw new Error(`Unhandled role: ${role}, id: ${claimRow.agent_id}`) | |
| 109 | + } | |
| 98 | 110 | if (request.claimed_agent_count >= MAX_AGENTS_PER_OWNER) return refuse('CONFLICT', `You can claim at most ${MAX_AGENTS_PER_OWNER} agents`) |
| 99 | 111 | |
| 100 | 112 | await trx('vk_access_requests').where({ id: request.id }).increment('claimed_agent_count', 1) |
| 101 | 113 | await trx('vk_agent_claims').where({ id: claimRow.id }).update({ claimed_by_user_id: user_id, claimed_at: now }) |
| 102 | - await agents.activate(ctx, trx, { agent_id: claimRow.agent_id, owner_user_id: user_id, guild_id, claimed_at: now }) | |
| 114 | + await agents.activate(ctx, trx, { agent_id: claimRow.agent_id, owner_user_id: user_id, guild_id, role, claimed_at: now }) | |
| 103 | 115 | await trx('vk_guild_members').insert({ id: `gm_${randomUUID()}`, guild_id, user_id, role: 'member', created_at: now }).onConflict(['guild_id', 'user_id']).ignore() |
| 104 | 116 | const agent = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) |
| 105 | 117 | await grantStarter(ctx, trx, { agent_id: agent.id, wallet_user_id: agent.wallet_user_id }) | ... | ... |
packages/voko-clearing/src/escrow.js
| ... | ... | @@ -5,9 +5,10 @@ const ESCROWS = 'vk_escrows' |
| 5 | 5 | const PAYOUTS = 'vk_escrow_payouts' |
| 6 | 6 | const SOURCE = 'voko' |
| 7 | 7 | const REF_TYPE = 'vk_task' |
| 8 | +const PAYOUT_REASON = Object.freeze({ winner: 'vk_task_payout', relay_share: 'vk_relay_share' }) | |
| 8 | 9 | |
| 9 | 10 | /** |
| 10 | - * 托管:发布时从发布方钱包扣款并记 held,退回时原路发还。 | |
| 11 | + * 托管:发布时从发布方钱包扣款并记 held,结算时发给接单方,退回时原路发还。 | |
| 11 | 12 | * 所有方法都在调用方的事务(`trx`)里动账,业务表与账本一起提交或回滚;幂等键由任务唯一决定。 |
| 12 | 13 | * 余额不足时账本抛 `BillingError(INSUFFICIENT_BALANCE)`,整个事务回滚。 |
| 13 | 14 | * |
| ... | ... | @@ -74,6 +75,35 @@ export function createEscrow({ db }) { |
| 74 | 75 | } |
| 75 | 76 | |
| 76 | 77 | /** |
| 78 | + * 结算:按给定的发放明细把托管发给接单方(含接力分成),escrow → released。 | |
| 79 | + * 各笔之和必须等于托管金额,否则 throw(宁可 500,不写脏账)。 | |
| 80 | + * @param {object} _ctx | |
| 81 | + * @param {import('knex').Knex} trx | |
| 82 | + * @param {{ task_id: string, payouts: Array<{ wallet_user_id: string, amount: number, kind: 'winner'|'relay_share' }> }} input | |
| 83 | + */ | |
| 84 | + async function settle(_ctx, trx, { task_id, payouts }) { | |
| 85 | + const escrow = await heldEscrow(trx, task_id) | |
| 86 | + const total = payouts.reduce((sum, payout) => sum + payout.amount, 0) | |
| 87 | + if (total !== escrow.amount) throw new Error(`vk_escrows settle payouts ${total} do not equal escrow amount ${escrow.amount}, id: ${escrow.id}`) | |
| 88 | + const now = new Date() | |
| 89 | + // 固定按钱包 id 排序动账,避免两笔结算互相等待对方的钱包行。 | |
| 90 | + for (const payout of [...payouts].sort((a, b) => (a.wallet_user_id < b.wallet_user_id ? -1 : 1))) { | |
| 91 | + const granted = await ledgerIn(trx).grant({ | |
| 92 | + user_id: payout.wallet_user_id, | |
| 93 | + amount: payout.amount, | |
| 94 | + source: SOURCE, | |
| 95 | + credit_kind: 'earned', | |
| 96 | + reason: PAYOUT_REASON[payout.kind], | |
| 97 | + ref_type: REF_TYPE, | |
| 98 | + ref_id: task_id, | |
| 99 | + idempotency_key: `vk:payout:${task_id}:${payout.wallet_user_id}:${payout.kind}`, | |
| 100 | + }) | |
| 101 | + await trx(PAYOUTS).insert({ id: `ep_${randomUUID()}`, escrow_id: escrow.id, task_id, recipient_wallet: payout.wallet_user_id, amount: payout.amount, kind: payout.kind, ledger_id: granted.ledger_id, created_at: now }) | |
| 102 | + } | |
| 103 | + await trx(ESCROWS).where({ id: escrow.id }).update({ status: 'released', closed_at: now }) | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** | |
| 77 | 107 | * 钱包视图:可用 = 账本余额;冻结 = 该钱包名下 held 托管之和。 |
| 78 | 108 | * @param {object} _ctx |
| 79 | 109 | * @param {{ wallet_user_id: string }} input |
| ... | ... | @@ -100,7 +130,7 @@ export function createEscrow({ db }) { |
| 100 | 130 | return -Number(row.spent) |
| 101 | 131 | } |
| 102 | 132 | |
| 103 | - return { hold, topupExists, topup, refund, walletOf, spentToday } | |
| 133 | + return { hold, topupExists, topup, refund, settle, walletOf, spentToday } | |
| 104 | 134 | } |
| 105 | 135 | |
| 106 | 136 | /** | ... | ... |
packages/voko-clearing/src/index.js
packages/voko-exchange/src/agents.js
| ... | ... | @@ -110,10 +110,10 @@ export function createAgents() { |
| 110 | 110 | * 认领:pending_claim → active。行不在 pending_claim 即 throw(调用方已锁认领凭证,不应发生)。 |
| 111 | 111 | * @param {object} _ctx |
| 112 | 112 | * @param {import('knex').Knex} runner |
| 113 | - * @param {{ agent_id: string, owner_user_id: string, guild_id: string, claimed_at: Date }} input | |
| 113 | + * @param {{ agent_id: string, owner_user_id: string, guild_id: string, role: 'company'|'member', claimed_at: Date }} input | |
| 114 | 114 | */ |
| 115 | - async function activate(_ctx, runner, { agent_id, owner_user_id, guild_id, claimed_at }) { | |
| 116 | - const updated = await runner(TABLE).where({ id: agent_id, status: 'pending_claim' }).update({ status: 'active', owner_user_id, guild_id, claimed_at, updated_at: claimed_at }) | |
| 115 | + async function activate(_ctx, runner, { agent_id, owner_user_id, guild_id, role, claimed_at }) { | |
| 116 | + const updated = await runner(TABLE).where({ id: agent_id, status: 'pending_claim' }).update({ status: 'active', role, owner_user_id, guild_id, claimed_at, updated_at: claimed_at }) | |
| 117 | 117 | if (updated !== 1) throw new Error(`vk_agents activate expected pending_claim row, id: ${agent_id}`) |
| 118 | 118 | } |
| 119 | 119 | ... | ... |
packages/voko-exchange/src/guilds.js
| ... | ... | @@ -15,11 +15,11 @@ export function createGuilds({ db }) { |
| 15 | 15 | /** |
| 16 | 16 | * 建公会并把负责人登记为成员。slug 已被占用返回 CONFLICT(由 UNIQUE 约束裁决)。 |
| 17 | 17 | * @param {object} _ctx |
| 18 | - * @param {{ name: string, slug: string, lead_user_id: string, require_lead_approval: boolean }} input | |
| 18 | + * @param {{ name: string, slug: string, lead_user_id: string, require_lead_approval: boolean, checkpoint_interval_sec?: number, piece_review_window_sec?: number, contest_review_window_sec?: number, transfer_ttl_sec?: number }} input 四个时间规则不传时用列默认值(6h / 24h / 72h / 24h) | |
| 19 | 19 | * @returns {Promise<{ ok: true, guild: Guild } | { ok: false, code: 'CONFLICT' }>} |
| 20 | 20 | */ |
| 21 | - async function create(_ctx, { name, slug, lead_user_id, require_lead_approval }) { | |
| 22 | - const guild = { id: `g_${randomUUID()}`, name, slug, lead_user_id, require_lead_approval, created_at: new Date() } | |
| 21 | + async function create(_ctx, { name, slug, lead_user_id, require_lead_approval, ...windows }) { | |
| 22 | + const guild = { id: `g_${randomUUID()}`, name, slug, lead_user_id, require_lead_approval, ...windows, created_at: new Date() } | |
| 23 | 23 | return db.transaction(async (trx) => { |
| 24 | 24 | // slug 冲突由 UNIQUE 裁决:被忽略的插入读不回本次 id。 |
| 25 | 25 | await trx(TABLE).insert(guild).onConflict('slug').ignore() | ... | ... |
packages/voko-exchange/src/jobs.js
| ... | ... | @@ -17,6 +17,7 @@ const WARN_KINDS = Object.freeze(['checkpoint_warn', 'review_warn']) |
| 17 | 17 | * tasks: { |
| 18 | 18 | * actAsWorker(ctx: object, input: { task_id: string, action: any }): Promise<{ ok: boolean }>, |
| 19 | 19 | * warn(ctx: object, input: { task_id: string, kind: any }): Promise<void>, |
| 20 | + * expireTransfers(ctx: object, input: { task_id: string }): Promise<void>, | |
| 20 | 21 | * }, |
| 21 | 22 | * logger: { error(message: string, fields: object): void }, |
| 22 | 23 | * }} deps |
| ... | ... | @@ -68,13 +69,19 @@ export function createJobs({ db, tasks, logger }) { |
| 68 | 69 | * @param {{ id: string, kind: string, ref_id: string|null }} job |
| 69 | 70 | */ |
| 70 | 71 | async function execute(ctx, job) { |
| 71 | - if (TASK_ACTION_KINDS.includes(job.kind) || WARN_KINDS.includes(job.kind)) { | |
| 72 | - if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`) | |
| 73 | - if (WARN_KINDS.includes(job.kind)) await tasks.warn(ctx, { task_id: job.ref_id, kind: job.kind }) | |
| 74 | - else await tasks.actAsWorker(ctx, { task_id: job.ref_id, action: { type: job.kind } }) | |
| 75 | - return | |
| 72 | + if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`) | |
| 73 | + const task_id = job.ref_id | |
| 74 | + if (TASK_ACTION_KINDS.includes(job.kind)) return void await tasks.actAsWorker(ctx, { task_id, action: { type: job.kind } }) | |
| 75 | + if (WARN_KINDS.includes(job.kind)) return tasks.warn(ctx, { task_id, kind: job.kind }) | |
| 76 | + if (job.kind === 'transfer_expire') return tasks.expireTransfers(ctx, { task_id }) | |
| 77 | + if (job.kind === 'review_auto_accept') { | |
| 78 | + const row = await db('vk_tasks').where({ id: task_id }).first('template', 'status') | |
| 79 | + if (row === undefined) throw new Error(`vk_tasks row missing for review_auto_accept, id: ${task_id}`) | |
| 80 | + // 擂台赛到期要由大模型按清单选赢家;大模型尚未装配,这里明确失败,不替它做决定(发布方仍可手动选赢家)。 | |
| 81 | + if (row.template === 'contest' && row.status === 'reviewing') throw new Error(`review_auto_accept for a contest needs the LLM judge, which is not assembled yet, id: ${task_id}`) | |
| 82 | + return void await tasks.actAsWorker(ctx, { task_id, action: { type: 'review_auto_accept', winner_agent_id: null } }) | |
| 76 | 83 | } |
| 77 | - // 其余类型(自动确认、仲裁、Webhook、日报、对账)在 W3–W4 接入。 | |
| 84 | + // 其余类型(仲裁意见、Webhook、日报、对账)在大模型装配后与 W4 接入。 | |
| 78 | 85 | throw new Error(`Unhandled job kind: ${job.kind}, id: ${job.id}`) |
| 79 | 86 | } |
| 80 | 87 | ... | ... |
packages/voko-exchange/src/review.js
0 → 100644
| 1 | +/** | |
| 2 | + * 验收格式校验(平台只校验格式,判断由发布方做):checks 必须恰好覆盖全部清单项; | |
| 3 | + * 任一条不通过时动作必须是 reject,全部通过时必须是 accept / select_winner。 | |
| 4 | + * | |
| 5 | + * @param {{ action: 'accept'|'reject'|'select_winner', checks: Array<{ id: string, pass: boolean, reason: string }>, item_keys: string[] }} input | |
| 6 | + * @returns {string|null} 违反规则时返回给调用方的提示;合规返回 null | |
| 7 | + */ | |
| 8 | +export function reviewViolation({ action, checks, item_keys }) { | |
| 9 | + const seen = new Set(checks.map((check) => check.id)) | |
| 10 | + if (seen.size !== checks.length) return 'checks contains the same acceptance item more than once.' | |
| 11 | + const missing = item_keys.filter((key) => !seen.has(key)) | |
| 12 | + if (missing.length > 0) return `checks must cover every acceptance item; missing: ${missing.join(', ')}.` | |
| 13 | + const unknown = checks.filter((check) => !item_keys.includes(check.id)).map((check) => check.id) | |
| 14 | + if (unknown.length > 0) return `checks contains unknown acceptance items: ${unknown.join(', ')}.` | |
| 15 | + const allPass = checks.every((check) => check.pass) | |
| 16 | + switch (action) { | |
| 17 | + case 'accept': | |
| 18 | + case 'select_winner': | |
| 19 | + return allPass ? null : `${action} requires every acceptance item to pass; reject instead.` | |
| 20 | + case 'reject': | |
| 21 | + return allPass ? 'reject requires at least one failed acceptance item; accept instead.' : null | |
| 22 | + default: | |
| 23 | + throw new Error(`Unhandled review action: ${action}`) | |
| 24 | + } | |
| 25 | +} | ... | ... |
packages/voko-exchange/src/split-relay.js
0 → 100644
| 1 | +/** | |
| 2 | + * 接力分配(2026-10-09 定:平分)。参与分配的人 = 最终提交者 + 所有留下过有效检查点后离开的前任 | |
| 3 | + * (has_valid_checkpoint 且 end_reason ∈ timeout / released / transferred);同一 Agent 多次持有只算一份。 | |
| 4 | + * 赏金按人数平分,除不尽的余数归最终提交者。各笔之和必须等于 reward。 | |
| 5 | + * | |
| 6 | + * @param {number} reward 正整数 | |
| 7 | + * @param {Array<{ agent_id: string, end_reason: 'submitted'|'released'|'timeout'|'transferred'|null, has_valid_checkpoint: boolean }>} holds 该任务的全部持有记录 | |
| 8 | + * @param {string} final_agent_id 最终提交者 | |
| 9 | + * @returns {Array<{ agent_id: string, amount: number, kind: 'winner'|'relay_share' }>} 最终提交者排第一 | |
| 10 | + */ | |
| 11 | +export function splitRelayPayout(reward, holds, final_agent_id) { | |
| 12 | + if (!Number.isSafeInteger(reward) || reward < 1) throw new Error(`splitRelayPayout: reward invalid: ${reward}`) | |
| 13 | + const relays = new Set() | |
| 14 | + for (const hold of holds) { | |
| 15 | + switch (hold.end_reason) { | |
| 16 | + case 'released': | |
| 17 | + case 'timeout': | |
| 18 | + case 'transferred': | |
| 19 | + if (hold.has_valid_checkpoint && hold.agent_id !== final_agent_id) relays.add(hold.agent_id) | |
| 20 | + break | |
| 21 | + case 'submitted': | |
| 22 | + case null: | |
| 23 | + break | |
| 24 | + default: | |
| 25 | + throw new Error(`Unhandled end_reason: ${hold.end_reason}, agent: ${hold.agent_id}`) | |
| 26 | + } | |
| 27 | + } | |
| 28 | + const share = Math.floor(reward / (relays.size + 1)) | |
| 29 | + // 人数多于积分时前任分不到整数份:全部归最终提交者(发放明细金额必须 > 0)。 | |
| 30 | + const paidRelays = share === 0 ? [] : [...relays] | |
| 31 | + const payouts = [ | |
| 32 | + { agent_id: final_agent_id, amount: reward - share * paidRelays.length, kind: /** @type {const} */ ('winner') }, | |
| 33 | + ...paidRelays.map((agent_id) => ({ agent_id, amount: share, kind: /** @type {const} */ ('relay_share') })), | |
| 34 | + ] | |
| 35 | + if (payouts.reduce((sum, payout) => sum + payout.amount, 0) !== reward) throw new Error(`splitRelayPayout: payouts do not sum to reward ${reward}`) | |
| 36 | + return payouts | |
| 37 | +} | ... | ... |
packages/voko-exchange/src/tasks.js
| 1 | 1 | import { randomUUID } from 'node:crypto' |
| 2 | 2 | |
| 3 | 3 | import { maxRarityForLevel, rarityForReward } from './rarity.js' |
| 4 | +import { reviewViolation } from './review.js' | |
| 5 | +import { splitRelayPayout } from './split-relay.js' | |
| 4 | 6 | import { taskFromRow } from './task.js' |
| 5 | 7 | import { publishStatus, transition } from './transition.js' |
| 6 | 8 | |
| ... | ... | @@ -9,6 +11,11 @@ const ITEMS = 'vk_task_acceptance_items' |
| 9 | 11 | const HOLDS = 'vk_task_holds' |
| 10 | 12 | const ENTRIES = 'vk_task_entries' |
| 11 | 13 | const COMMENTS = 'vk_comments' |
| 14 | +const CHECKPOINTS = 'vk_checkpoints' | |
| 15 | +const SUBMISSIONS = 'vk_submissions' | |
| 16 | +const REVIEWS = 'vk_reviews' | |
| 17 | +const DISPUTES = 'vk_disputes' | |
| 18 | +const TRANSFERS = 'vk_transfers' | |
| 12 | 19 | const EVENTS = 'vk_events' |
| 13 | 20 | const JOBS = 'vk_jobs' |
| 14 | 21 | |
| ... | ... | @@ -30,6 +37,7 @@ const JOBS = 'vk_jobs' |
| 30 | 37 | * topupExists(ctx: object, trx: import('knex').Knex, input: { task_id: string, idempotency_key: string }): Promise<boolean>, |
| 31 | 38 | * topup(ctx: object, trx: import('knex').Knex, input: { task_id: string, amount: number, idempotency_key: string }): Promise<void>, |
| 32 | 39 | * refund(ctx: object, trx: import('knex').Knex, input: { task_id: string }): Promise<void>, |
| 40 | + * settle(ctx: object, trx: import('knex').Knex, input: { task_id: string, payouts: Array<{ wallet_user_id: string, amount: number, kind: 'winner'|'relay_share' }> }): Promise<void>, | |
| 33 | 41 | * spentToday(ctx: object, trx: import('knex').Knex, input: { wallet_user_id: string, now: Date }): Promise<number>, |
| 34 | 42 | * }, |
| 35 | 43 | * }} deps |
| ... | ... | @@ -83,16 +91,25 @@ export function createTasks({ db, agents, escrow }) { |
| 83 | 91 | |
| 84 | 92 | /** |
| 85 | 93 | * Agent 对任务做一个动作。`idempotency_key` 只用于 edit 的补扣托管。 |
| 94 | + * `prepare` 在锁住任务后、跑状态机前执行(读其它表做校验,或据此给出动作);`record` 在状态与副作用写完后执行(写检查点、提交、验收等记录)。 | |
| 95 | + * @template R | |
| 86 | 96 | * @param {object} ctx |
| 87 | - * @param {{ agent: Agent, task_id: string, action: import('./transition.js').Action, idempotency_key?: string }} input | |
| 88 | - * @returns {Promise<{ ok: true, task: Task } | Refusal>} | |
| 97 | + * @param {{ | |
| 98 | + * agent: Agent, | |
| 99 | + * task_id: string, | |
| 100 | + * action?: import('./transition.js').Action, | |
| 101 | + * idempotency_key?: string, | |
| 102 | + * prepare?: (trx: import('knex').Knex, task: Task, now: Date) => Promise<Refusal | { action: import('./transition.js').Action } | null>, | |
| 103 | + * record?: (trx: import('knex').Knex, before: Task, after: Task, now: Date) => Promise<R>, | |
| 104 | + * }} input | |
| 105 | + * @returns {Promise<{ ok: true, task: Task, recorded: R } | Refusal>} | |
| 89 | 106 | */ |
| 90 | - async function act(ctx, { agent, task_id, action, idempotency_key }) { | |
| 107 | + async function act(ctx, { agent, task_id, action, idempotency_key, prepare, record }) { | |
| 91 | 108 | return db.transaction(async (trx) => { |
| 92 | 109 | const task = await lockTask(trx, task_id) |
| 93 | 110 | const hidden = task === null ? refuse('not_found', `Task not found: ${task_id}`) : hiddenFrom(agent, task) |
| 94 | 111 | if (task === null || hidden !== null) return /** @type {Refusal} */ (hidden) |
| 95 | - if (action.type === 'edit' && await escrow.topupExists(ctx, trx, { task_id, idempotency_key: /** @type {string} */ (idempotency_key) })) return { ok: true, task } | |
| 112 | + if (action !== undefined && action.type === 'edit' && await escrow.topupExists(ctx, trx, { task_id, idempotency_key: /** @type {string} */ (idempotency_key) })) return { ok: true, task, recorded: /** @type {R} */ (undefined) } | |
| 96 | 113 | // 锁 Agent 行:同一 Agent 并发认领不同任务时,持有数的读取与新增串行。 |
| 97 | 114 | await trx('vk_agents').where({ id: agent.id }).forUpdate().first('id') |
| 98 | 115 | const [holds, entry] = await Promise.all([ |
| ... | ... | @@ -108,7 +125,12 @@ export function createTasks({ db, agents, escrow }) { |
| 108 | 125 | active_hold_count: Number(holds.n), |
| 109 | 126 | is_entrant: entry !== undefined, |
| 110 | 127 | } |
| 111 | - return apply(ctx, trx, task, action, actor, idempotency_key) | |
| 128 | + const now = new Date() | |
| 129 | + const prepared = prepare === undefined ? null : await prepare(trx, task, now) | |
| 130 | + if (prepared !== null && 'ok' in prepared) return prepared | |
| 131 | + const chosen = prepared === null ? action : prepared.action | |
| 132 | + if (chosen === undefined) throw new Error(`act: no action for task ${task_id}`) | |
| 133 | + return apply(ctx, trx, task, chosen, actor, idempotency_key, now, record) | |
| 112 | 134 | }) |
| 113 | 135 | } |
| 114 | 136 | |
| ... | ... | @@ -122,7 +144,7 @@ export function createTasks({ db, agents, escrow }) { |
| 122 | 144 | return db.transaction(async (trx) => { |
| 123 | 145 | const task = await lockTask(trx, task_id) |
| 124 | 146 | if (task === null) return refuse('not_found', `Task not found: ${task_id}`) |
| 125 | - return apply(ctx, trx, task, action, { kind: 'worker' }, undefined) | |
| 147 | + return apply(ctx, trx, task, action, { kind: 'worker' }, undefined, new Date(), undefined) | |
| 126 | 148 | }) |
| 127 | 149 | } |
| 128 | 150 | |
| ... | ... | @@ -133,10 +155,11 @@ export function createTasks({ db, agents, escrow }) { |
| 133 | 155 | * @param {import('./transition.js').Action} action |
| 134 | 156 | * @param {import('./transition.js').Actor} actor |
| 135 | 157 | * @param {string|undefined} idempotency_key |
| 136 | - * @returns {Promise<{ ok: true, task: Task } | Refusal>} | |
| 158 | + * @param {Date} now | |
| 159 | + * @param {((trx: import('knex').Knex, before: Task, after: Task, now: Date) => Promise<any>)|undefined} record | |
| 160 | + * @returns {Promise<{ ok: true, task: Task, recorded: any } | Refusal>} | |
| 137 | 161 | */ |
| 138 | - async function apply(ctx, trx, task, action, actor, idempotency_key) { | |
| 139 | - const now = new Date() | |
| 162 | + async function apply(ctx, trx, task, action, actor, idempotency_key, now, record) { | |
| 140 | 163 | const guild = await guildRow(trx, task.guild_id) |
| 141 | 164 | const publisher = await agents.getById(ctx, trx, { agent_id: task.publisher_agent_id }) |
| 142 | 165 | const result = transition({ task, guild, publisher_owner_user_id: /** @type {string} */ (publisher.owner_user_id) }, action, actor, now) |
| ... | ... | @@ -182,14 +205,46 @@ export function createTasks({ db, agents, escrow }) { |
| 182 | 205 | await insertJob(trx, task.id, effect.kind, effect.run_at, now) |
| 183 | 206 | break |
| 184 | 207 | case 'escrow_settle': |
| 208 | + await settle(ctx, trx, next, effect.winner_agent_id) | |
| 209 | + break | |
| 185 | 210 | case 'dispute_open': |
| 186 | - // 结算(W4)与仲裁(W3)的执行尚未实现;现有接口到不了这里。 | |
| 187 | - throw new Error(`Effect not implemented yet: ${effect.type}, id: ${task.id}`) | |
| 211 | + await trx(DISPUTES).insert({ id: `d_${randomUUID()}`, task_id: task.id, opened_by_agent_id: effect.opened_by_agent_id, status: 'open', created_at: now }) | |
| 212 | + break | |
| 188 | 213 | default: |
| 189 | 214 | throw new Error(`Unhandled effect: ${/** @type {any} */ (effect).type}, id: ${task.id}`) |
| 190 | 215 | } |
| 191 | 216 | } |
| 192 | - return { ok: true, task: { ...next, updated_at: now } } | |
| 217 | + const after = { ...next, updated_at: now } | |
| 218 | + return { ok: true, task: after, recorded: record === undefined ? undefined : await record(trx, task, after, now) } | |
| 219 | + } | |
| 220 | + | |
| 221 | + /** | |
| 222 | + * 结算:计件活按接力规则在最终提交者与留过有效检查点的前任之间平分,擂台赛赢家通吃;收款人按到手积分加 XP。 | |
| 223 | + * @param {object} ctx | |
| 224 | + * @param {import('knex').Knex} trx | |
| 225 | + * @param {Task} task | |
| 226 | + * @param {string} winner_agent_id | |
| 227 | + */ | |
| 228 | + async function settle(ctx, trx, task, winner_agent_id) { | |
| 229 | + /** @type {Array<{ agent_id: string, amount: number, kind: 'winner'|'relay_share' }>} */ | |
| 230 | + let shares | |
| 231 | + switch (task.template) { | |
| 232 | + case 'piecework': | |
| 233 | + shares = splitRelayPayout(task.reward, (await trx(HOLDS).where({ task_id: task.id }).select('agent_id', 'end_reason', 'has_valid_checkpoint')).map((hold) => ({ ...hold, has_valid_checkpoint: hold.has_valid_checkpoint === 1 })), winner_agent_id) | |
| 234 | + break | |
| 235 | + case 'contest': | |
| 236 | + shares = [{ agent_id: winner_agent_id, amount: task.reward, kind: 'winner' }] | |
| 237 | + break | |
| 238 | + default: | |
| 239 | + throw new Error(`Unhandled template: ${task.template}, id: ${task.id}`) | |
| 240 | + } | |
| 241 | + const payouts = [] | |
| 242 | + for (const share of shares) { | |
| 243 | + const recipient = await agents.getById(ctx, trx, { agent_id: share.agent_id }) | |
| 244 | + payouts.push({ wallet_user_id: recipient.wallet_user_id, amount: share.amount, kind: share.kind }) | |
| 245 | + await trx('vk_agents').where({ id: recipient.id }).increment('xp', share.amount) | |
| 246 | + } | |
| 247 | + await escrow.settle(ctx, trx, { task_id: task.id, payouts }) | |
| 193 | 248 | } |
| 194 | 249 | |
| 195 | 250 | /** |
| ... | ... | @@ -220,7 +275,7 @@ export function createTasks({ db, agents, escrow }) { |
| 220 | 275 | /** |
| 221 | 276 | * @param {object} _ctx |
| 222 | 277 | * @param {{ agent: Agent, task_id: string }} input |
| 223 | - * @returns {Promise<{ ok: true, task: Task, acceptance: AcceptanceItem[], entry_count: number } | Refusal>} | |
| 278 | + * @returns {Promise<{ ok: true, task: Task, acceptance: AcceptanceItem[], entry_count: number, checkpoints: any[], submissions: any[] } | Refusal>} | |
| 224 | 279 | */ |
| 225 | 280 | async function get(_ctx, { agent, task_id }) { |
| 226 | 281 | const row = await db(TASKS).where({ id: task_id }).first() |
| ... | ... | @@ -228,8 +283,15 @@ export function createTasks({ db, agents, escrow }) { |
| 228 | 283 | const task = taskFromRow(row) |
| 229 | 284 | const hidden = hiddenFrom(agent, task) |
| 230 | 285 | if (hidden !== null) return hidden |
| 231 | - const entries = await db(ENTRIES).where({ task_id }).count({ n: '*' }).first() | |
| 232 | - return { ok: true, task, acceptance: await acceptanceOf(db, task_id), entry_count: Number(entries.n) } | |
| 286 | + const [entries, checkpoints, submissions] = await Promise.all([ | |
| 287 | + db(ENTRIES).where({ task_id }).count({ n: '*' }).first(), | |
| 288 | + db(CHECKPOINTS).where({ task_id }).orderBy('created_at', 'asc').select('id', 'agent_id', 'progress', 'note', 'links', 'created_at'), | |
| 289 | + db(SUBMISSIONS).where({ task_id }).orderBy('created_at', 'asc').select('id', 'agent_id', 'round', 'summary', 'artifacts', 'created_at'), | |
| 290 | + ]) | |
| 291 | + // 交付只给发布方和提交者本人看;擂台赛截止前对发布方也封存。 | |
| 292 | + const sealed = task.status === 'in_contest' | |
| 293 | + const visible = submissions.filter((submission) => submission.agent_id === agent.id || (task.publisher_agent_id === agent.id && !sealed)) | |
| 294 | + return { ok: true, task, acceptance: await acceptanceOf(db, task_id), entry_count: Number(entries.n), checkpoints, submissions: visible } | |
| 233 | 295 | } |
| 234 | 296 | |
| 235 | 297 | /** |
| ... | ... | @@ -289,6 +351,219 @@ export function createTasks({ db, agents, escrow }) { |
| 289 | 351 | } |
| 290 | 352 | |
| 291 | 353 | /** |
| 354 | + * 上报检查点:刷新下次到期时间,并记为该次持有的有效检查点(接力分成的依据)。 | |
| 355 | + * @param {object} ctx | |
| 356 | + * @param {{ agent: Agent, task_id: string, progress: number, note: string|null, links: string[] }} input | |
| 357 | + */ | |
| 358 | + async function checkpoint(ctx, { agent, task_id, progress, note, links }) { | |
| 359 | + return act(ctx, { | |
| 360 | + agent, | |
| 361 | + task_id, | |
| 362 | + action: { type: 'checkpoint' }, | |
| 363 | + async record(trx, _before, _after, now) { | |
| 364 | + const hold = await trx(HOLDS).where({ task_id }).whereNull('ended_at').first('id') | |
| 365 | + const id = `cp_${randomUUID()}` | |
| 366 | + await trx(CHECKPOINTS).insert({ id, task_id, hold_id: hold.id, agent_id: agent.id, progress, note, links: JSON.stringify(links), created_at: now }) | |
| 367 | + return { checkpoint_id: id } | |
| 368 | + }, | |
| 369 | + }) | |
| 370 | + } | |
| 371 | + | |
| 372 | + /** | |
| 373 | + * 提交交付物。计件活每轮一份(第 n 次返工是第 n+1 轮);擂台赛每个参赛者一份,截止前封存。 | |
| 374 | + * @param {object} ctx | |
| 375 | + * @param {{ agent: Agent, task_id: string, summary: string, artifacts: Array<{ type: 'link', url: string, note?: string }> }} input | |
| 376 | + */ | |
| 377 | + async function submit(ctx, { agent, task_id, summary, artifacts }) { | |
| 378 | + return act(ctx, { | |
| 379 | + agent, | |
| 380 | + task_id, | |
| 381 | + action: { type: 'submit' }, | |
| 382 | + async prepare(trx, task) { | |
| 383 | + if (task.template !== 'contest') return null | |
| 384 | + const existing = await trx(SUBMISSIONS).where({ task_id, agent_id: agent.id }).first('id') | |
| 385 | + return existing === undefined ? null : refuse('state_conflict', 'You have already submitted to this contest.') | |
| 386 | + }, | |
| 387 | + async record(trx, before, _after, now) { | |
| 388 | + const id = `s_${randomUUID()}` | |
| 389 | + await trx(SUBMISSIONS).insert({ id, task_id, agent_id: agent.id, round: before.template === 'contest' ? 1 : before.rework_count + 1, summary, artifacts: JSON.stringify(artifacts), created_at: now }) | |
| 390 | + return { submission_id: id } | |
| 391 | + }, | |
| 392 | + }) | |
| 393 | + } | |
| 394 | + | |
| 395 | + /** | |
| 396 | + * 发布方按清单逐条验收。计件活验的是持有人最新一轮提交;擂台赛用 select_winner 并指明赢家的提交。 | |
| 397 | + * @param {object} ctx | |
| 398 | + * @param {{ agent: Agent, task_id: string, action: 'accept'|'reject'|'select_winner', checks: Array<{ id: string, pass: boolean, reason: string }>, reason: string|null, submission_id: string|null }} input | |
| 399 | + */ | |
| 400 | + async function review(ctx, { agent, task_id, action, checks, reason, submission_id }) { | |
| 401 | + /** @type {string} */ | |
| 402 | + let reviewed | |
| 403 | + return act(ctx, { | |
| 404 | + agent, | |
| 405 | + task_id, | |
| 406 | + async prepare(trx) { | |
| 407 | + const violation = reviewViolation({ action, checks, item_keys: (await acceptanceOf(trx, task_id)).map((item) => item.id) }) | |
| 408 | + if (violation !== null) return refuse('rule_violation', violation) | |
| 409 | + switch (action) { | |
| 410 | + case 'accept': | |
| 411 | + case 'reject': { | |
| 412 | + const latest = await trx(SUBMISSIONS).where({ task_id }).orderBy('created_at', 'desc').first('id') | |
| 413 | + if (latest === undefined) return refuse('state_conflict', 'There is no submission to review yet.') | |
| 414 | + reviewed = latest.id | |
| 415 | + return { action: { type: action } } | |
| 416 | + } | |
| 417 | + case 'select_winner': { | |
| 418 | + const winner = submission_id === null ? undefined : await trx(SUBMISSIONS).where({ id: submission_id, task_id }).first('id', 'agent_id') | |
| 419 | + if (winner === undefined) return refuse('rule_violation', 'select_winner requires the submission_id of a submission to this task.') | |
| 420 | + reviewed = winner.id | |
| 421 | + return { action: { type: 'select_winner', winner_agent_id: winner.agent_id } } | |
| 422 | + } | |
| 423 | + default: | |
| 424 | + throw new Error(`Unhandled review action: ${action}, id: ${task_id}`) | |
| 425 | + } | |
| 426 | + }, | |
| 427 | + async record(trx, _before, _after, now) { | |
| 428 | + const id = `rv_${randomUUID()}` | |
| 429 | + await trx(REVIEWS).insert({ id, task_id, submission_id: reviewed, reviewer_kind: 'publisher', action, reason, created_at: now }) | |
| 430 | + await trx('vk_review_checks').insert(checks.map((check) => ({ review_id: id, item_key: check.id, pass: check.pass, reason: check.reason }))) | |
| 431 | + return { review_id: id } | |
| 432 | + }, | |
| 433 | + }) | |
| 434 | + } | |
| 435 | + | |
| 436 | + /** | |
| 437 | + * 持有人把任务转给同公会的另一个 Agent,对方接受后才换人;同一任务同时只有一个待确认的转让。 | |
| 438 | + * @param {object} _ctx | |
| 439 | + * @param {{ agent: Agent, task_id: string, to_agent_id: string }} input | |
| 440 | + * @returns {Promise<{ ok: true, transfer_id: string, expires_at: Date } | Refusal>} | |
| 441 | + */ | |
| 442 | + async function requestTransfer(_ctx, { agent, task_id, to_agent_id }) { | |
| 443 | + return db.transaction(async (trx) => { | |
| 444 | + const task = await lockTask(trx, task_id) | |
| 445 | + const hidden = task === null ? refuse('not_found', `Task not found: ${task_id}`) : hiddenFrom(agent, task) | |
| 446 | + if (task === null || hidden !== null) return /** @type {Refusal} */ (hidden) | |
| 447 | + if (task.status !== 'claimed') return refuse('state_conflict', `Task is ${task.status}; only a claimed task can be transferred.`) | |
| 448 | + if (task.holder_agent_id !== agent.id) return refuse('forbidden', 'Only the current holder can transfer.') | |
| 449 | + const target = await trx('vk_agents').where({ id: to_agent_id, guild_id: task.guild_id, status: 'active' }).first('id') | |
| 450 | + if (target === undefined || to_agent_id === agent.id) return refuse('rule_violation', 'to_agent_id must be another active agent in this guild.') | |
| 451 | + if ((await trx(TRANSFERS).where({ task_id, status: 'pending' }).first('id')) !== undefined) return refuse('state_conflict', 'This task already has a pending transfer.') | |
| 452 | + const now = new Date() | |
| 453 | + const guild = await guildRow(trx, task.guild_id) | |
| 454 | + const transfer = { id: `tr_${randomUUID()}`, task_id, from_agent_id: agent.id, to_agent_id, status: 'pending', expires_at: new Date(now.getTime() + guild.transfer_ttl_sec * 1000), created_at: now } | |
| 455 | + await trx(TRANSFERS).insert(transfer) | |
| 456 | + await insertEvent(trx, task, 'task.transfer_requested', to_agent_id, now) | |
| 457 | + await insertJob(trx, task_id, 'transfer_expire', transfer.expires_at, now) | |
| 458 | + return { ok: true, transfer_id: transfer.id, expires_at: transfer.expires_at } | |
| 459 | + }) | |
| 460 | + } | |
| 461 | + | |
| 462 | + /** | |
| 463 | + * 被转让方接受或拒绝。接受时走状态机换持有人(自接自单、并发上限、稀有度同样适用)。 | |
| 464 | + * @param {object} ctx | |
| 465 | + * @param {{ agent: Agent, task_id: string, decision: 'accept'|'decline' }} input | |
| 466 | + * @returns {Promise<{ ok: true, task: Task } | Refusal>} | |
| 467 | + */ | |
| 468 | + async function answerTransfer(ctx, { agent, task_id, decision }) { | |
| 469 | + /** @param {import('knex').Knex} trx @param {Date} now */ | |
| 470 | + const pendingForMe = async (trx, now) => { | |
| 471 | + const transfer = await trx(TRANSFERS).where({ task_id, to_agent_id: agent.id, status: 'pending' }).first('id', 'expires_at') | |
| 472 | + return transfer === undefined || transfer.expires_at <= now ? null : transfer | |
| 473 | + } | |
| 474 | + switch (decision) { | |
| 475 | + case 'accept': | |
| 476 | + return act(ctx, { | |
| 477 | + agent, | |
| 478 | + task_id, | |
| 479 | + action: { type: 'transfer_accept' }, | |
| 480 | + prepare: async (trx, _task, now) => ((await pendingForMe(trx, now)) === null ? refuse('state_conflict', 'There is no pending transfer of this task to you.') : null), | |
| 481 | + record: async (trx, _before, _after, now) => { | |
| 482 | + await trx(TRANSFERS).where({ task_id, to_agent_id: agent.id, status: 'pending' }).update({ status: 'accepted', resolved_at: now }) | |
| 483 | + }, | |
| 484 | + }) | |
| 485 | + case 'decline': | |
| 486 | + return db.transaction(async (trx) => { | |
| 487 | + const task = await lockTask(trx, task_id) | |
| 488 | + const now = new Date() | |
| 489 | + if (task === null || (await pendingForMe(trx, now)) === null) return refuse('state_conflict', 'There is no pending transfer of this task to you.') | |
| 490 | + await trx(TRANSFERS).where({ task_id, to_agent_id: agent.id, status: 'pending' }).update({ status: 'declined', resolved_at: now }) | |
| 491 | + return { ok: true, task } | |
| 492 | + }) | |
| 493 | + default: | |
| 494 | + throw new Error(`Unhandled transfer decision: ${decision}, id: ${task_id}`) | |
| 495 | + } | |
| 496 | + } | |
| 497 | + | |
| 498 | + /** | |
| 499 | + * worker:到期仍待确认的转让记为 expired。 | |
| 500 | + * @param {object} _ctx | |
| 501 | + * @param {{ task_id: string }} input | |
| 502 | + */ | |
| 503 | + async function expireTransfers(_ctx, { task_id }) { | |
| 504 | + const now = new Date() | |
| 505 | + await db(TRANSFERS).where({ task_id, status: 'pending' }).where('expires_at', '<=', now).update({ status: 'expired', resolved_at: now }) | |
| 506 | + } | |
| 507 | + | |
| 508 | + /** | |
| 509 | + * 负责人审核公司 Agent 发布的任务:通过上板,驳回退款。 | |
| 510 | + * @param {object} ctx | |
| 511 | + * @param {{ user_id: string, task_id: string, decision: 'approve'|'reject' }} input | |
| 512 | + * @returns {Promise<{ ok: true, task: Task } | Refusal>} | |
| 513 | + */ | |
| 514 | + async function decideApproval(ctx, { user_id, task_id, decision }) { | |
| 515 | + return db.transaction(async (trx) => { | |
| 516 | + const task = await lockTask(trx, task_id) | |
| 517 | + if (task === null) return refuse('not_found', `Task not found: ${task_id}`) | |
| 518 | + const guild = await trx('vk_guilds').where({ id: task.guild_id }).first('lead_user_id') | |
| 519 | + if (guild.lead_user_id !== user_id) return refuse('forbidden', 'Only the guild lead can review this task.') | |
| 520 | + return apply(ctx, trx, task, { type: decision === 'approve' ? 'approve' : 'reject_approval' }, { kind: 'lead', user_id }, undefined, new Date(), undefined) | |
| 521 | + }) | |
| 522 | + } | |
| 523 | + | |
| 524 | + /** | |
| 525 | + * 负责人名下公会里等待审核的任务。 | |
| 526 | + * @param {object} _ctx | |
| 527 | + * @param {{ user_id: string }} input | |
| 528 | + * @returns {Promise<Task[]>} | |
| 529 | + */ | |
| 530 | + async function listPendingApprovals(_ctx, { user_id }) { | |
| 531 | + const rows = await db(TASKS).where({ status: 'pending_approval' }).whereIn('guild_id', db('vk_guilds').where({ lead_user_id: user_id }).select('id')).orderBy('created_at', 'asc') | |
| 532 | + return rows.map(taskFromRow) | |
| 533 | + } | |
| 534 | + | |
| 535 | + /** | |
| 536 | + * 运营裁决争议:结算给接单方(计件活为持有人,擂台赛须指明赢家)或全额退回发布方。 | |
| 537 | + * @param {object} ctx | |
| 538 | + * @param {{ user_id: string, task_id: string, outcome: 'settled'|'refunded', winner_agent_id: string|null }} input | |
| 539 | + * @returns {Promise<{ ok: true, task: Task } | Refusal>} | |
| 540 | + */ | |
| 541 | + async function resolveDispute(ctx, { user_id, task_id, outcome, winner_agent_id }) { | |
| 542 | + return db.transaction(async (trx) => { | |
| 543 | + const task = await lockTask(trx, task_id) | |
| 544 | + if (task === null) return refuse('not_found', `Task not found: ${task_id}`) | |
| 545 | + const winner = outcome === 'settled' ? (task.holder_agent_id === null ? winner_agent_id : task.holder_agent_id) : null | |
| 546 | + if (outcome === 'settled' && winner === null) return refuse('rule_violation', 'winner_agent_id is required to settle a contest dispute.') | |
| 547 | + if (winner !== null && task.template === 'contest' && (await trx(ENTRIES).where({ task_id, agent_id: winner }).first('id')) === undefined) return refuse('rule_violation', 'winner_agent_id must be an entrant of this contest.') | |
| 548 | + const now = new Date() | |
| 549 | + return apply(ctx, trx, task, { type: 'resolve', outcome, winner_agent_id: winner }, { kind: 'operator', user_id }, undefined, now, async () => { | |
| 550 | + await trx(DISPUTES).where({ task_id }).whereNot({ status: 'resolved' }).update({ status: 'resolved', resolution: JSON.stringify({ outcome, winner_agent_id: winner }), resolved_by_user_id: user_id, resolved_at: now }) | |
| 551 | + }) | |
| 552 | + }) | |
| 553 | + } | |
| 554 | + | |
| 555 | + /** | |
| 556 | + * 运营的仲裁队列:未关闭的争议及其任务。 | |
| 557 | + * @param {object} _ctx | |
| 558 | + * @returns {Promise<Array<{ dispute_id: string, status: string, opened_by_agent_id: string, created_at: Date, task: Task }>>} | |
| 559 | + */ | |
| 560 | + async function listOpenDisputes(_ctx) { | |
| 561 | + const disputes = await db(DISPUTES).whereNot({ status: 'resolved' }).orderBy('created_at', 'asc') | |
| 562 | + const tasks = disputes.length === 0 ? [] : await db(TASKS).whereIn('id', disputes.map((dispute) => dispute.task_id)) | |
| 563 | + return disputes.map((dispute) => ({ dispute_id: dispute.id, status: dispute.status, opened_by_agent_id: dispute.opened_by_agent_id, created_at: dispute.created_at, task: taskFromRow(tasks.find((task) => task.id === dispute.task_id)) })) | |
| 564 | + } | |
| 565 | + | |
| 566 | + /** | |
| 292 | 567 | * 到期前提醒:任务仍在等待对应动作时给当事人发事件;状态已变则无事可做。 |
| 293 | 568 | * @param {object} _ctx |
| 294 | 569 | * @param {{ task_id: string, kind: 'checkpoint_warn'|'review_warn' }} input |
| ... | ... | @@ -310,7 +585,7 @@ export function createTasks({ db, agents, escrow }) { |
| 310 | 585 | } |
| 311 | 586 | } |
| 312 | 587 | |
| 313 | - return { publish, act, actAsWorker, warn, list, get, addComment, listComments, homeFor } | |
| 588 | + return { publish, act, actAsWorker, checkpoint, submit, review, requestTransfer, answerTransfer, expireTransfers, decideApproval, listPendingApprovals, resolveDispute, listOpenDisputes, warn, list, get, addComment, listComments, homeFor } | |
| 314 | 589 | } |
| 315 | 590 | |
| 316 | 591 | /** |
| ... | ... | @@ -340,7 +615,7 @@ async function lockTask(trx, task_id) { |
| 340 | 615 | * @param {string} guild_id |
| 341 | 616 | */ |
| 342 | 617 | async function guildRow(runner, guild_id) { |
| 343 | - const guild = await runner('vk_guilds').where({ id: guild_id }).first('require_lead_approval', 'checkpoint_interval_sec', 'piece_review_window_sec', 'contest_review_window_sec') | |
| 618 | + const guild = await runner('vk_guilds').where({ id: guild_id }).first('require_lead_approval', 'checkpoint_interval_sec', 'piece_review_window_sec', 'contest_review_window_sec', 'transfer_ttl_sec') | |
| 344 | 619 | if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`) |
| 345 | 620 | return guild |
| 346 | 621 | } | ... | ... |
packages/voko-exchange/src/transition.js
| ... | ... | @@ -184,18 +184,18 @@ const TRANSITIONS = { |
| 184 | 184 | if (actor.kind !== 'worker') return notAllowed('Only the platform can record a missed checkpoint.') |
| 185 | 185 | const due = /** @type {Date} */ (task.next_checkpoint_due_at) |
| 186 | 186 | if (now < due) return fail('state_conflict', 'Checkpoint is not overdue.') |
| 187 | - if (task.missed_checkpoints >= 1) return ok(reopen(task), releaseEffects(task, 'timeout')) | |
| 187 | + if (task.missed_checkpoints >= 1) return released(task, 'timeout', now) | |
| 188 | 188 | // 第一次缺失只告警:从原到期时刻顺延一个间隔。 |
| 189 | 189 | return ok({ ...task, next_checkpoint_due_at: checkpointDue(state, due), missed_checkpoints: 1 }, checkpointJobs(state, due)) |
| 190 | 190 | }, |
| 191 | - release({ task }, _action, actor) { | |
| 191 | + release({ task }, _action, actor, now) { | |
| 192 | 192 | if (!isHolder(task, actor)) return notAllowed('Only the current holder can release.') |
| 193 | - return ok(reopen(task), releaseEffects(task, 'released')) | |
| 193 | + return released(task, 'released', now) | |
| 194 | 194 | }, |
| 195 | 195 | hold_timeout({ task }, _action, actor, now) { |
| 196 | 196 | if (actor.kind !== 'worker') return notAllowed('Only the platform can time out a hold.') |
| 197 | 197 | if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') |
| 198 | - return ok(reopen(task), releaseEffects(task, 'timeout')) | |
| 198 | + return released(task, 'timeout', now) | |
| 199 | 199 | }, |
| 200 | 200 | transfer_accept(state, _action, actor, now) { |
| 201 | 201 | const refused = takerRefusal(state, actor) |
| ... | ... | @@ -371,10 +371,25 @@ function hold(state, agentId, now) { |
| 371 | 371 | |
| 372 | 372 | /** |
| 373 | 373 | * @param {Task} task |
| 374 | + * @param {'released'|'timeout'} reason | |
| 375 | + * @param {Date} now | |
| 376 | + * @returns {TransitionResult} | |
| 377 | + */ | |
| 378 | +function released(task, reason, now) { | |
| 379 | + const reopened = reopen(task, now) | |
| 380 | + return ok(reopened, releaseEffects(task, reopened, reason)) | |
| 381 | +} | |
| 382 | + | |
| 383 | +/** | |
| 384 | + * 任务回到 open。若此时已过截止,按原先给的工期(created_at → deadline_at)从现在起重新计时, | |
| 385 | + * 否则任务既接不了也不会过期,会卡死。 | |
| 386 | + * @param {Task} task | |
| 387 | + * @param {Date} now | |
| 374 | 388 | * @returns {Task} |
| 375 | 389 | */ |
| 376 | -function reopen(task) { | |
| 377 | - return { ...task, status: 'open', holder_agent_id: null, next_checkpoint_due_at: null, missed_checkpoints: 0 } | |
| 390 | +function reopen(task, now) { | |
| 391 | + const deadline = now < task.deadline_at ? task.deadline_at : new Date(now.getTime() + (task.deadline_at.getTime() - task.created_at.getTime())) | |
| 392 | + return { ...task, status: 'open', deadline_at: deadline, holder_agent_id: null, next_checkpoint_due_at: null, missed_checkpoints: 0 } | |
| 378 | 393 | } |
| 379 | 394 | |
| 380 | 395 | /** |
| ... | ... | @@ -402,12 +417,19 @@ function submitted(state, now, before) { |
| 402 | 417 | } |
| 403 | 418 | |
| 404 | 419 | /** |
| 405 | - * @param {Task} task | |
| 420 | + * @param {Task} task 释放前的任务 | |
| 421 | + * @param {Task} reopened 释放后的任务 | |
| 406 | 422 | * @param {'released'|'timeout'} reason |
| 407 | 423 | * @returns {Effect[]} |
| 408 | 424 | */ |
| 409 | -function releaseEffects(task, reason) { | |
| 410 | - return [{ type: 'hold_end', reason }, event('task.released', /** @type {string} */ (task.holder_agent_id)), event('task.released', task.publisher_agent_id)] | |
| 425 | +function releaseEffects(task, reopened, reason) { | |
| 426 | + return [ | |
| 427 | + { type: 'hold_end', reason }, | |
| 428 | + event('task.released', /** @type {string} */ (task.holder_agent_id)), | |
| 429 | + event('task.released', task.publisher_agent_id), | |
| 430 | + // 截止被顺延时要按新截止重新排「过期无人接」。 | |
| 431 | + ...(reopened.deadline_at.getTime() === task.deadline_at.getTime() ? [] : openJobs(reopened)), | |
| 432 | + ] | |
| 411 | 433 | } |
| 412 | 434 | |
| 413 | 435 | /** | ... | ... |
packages/voko-exchange/test/review.test.js
0 → 100644
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { reviewViolation } from '../src/review.js' | |
| 3 | + | |
| 4 | +const item_keys = ['c1', 'c2'] | |
| 5 | +const pass = (id) => ({ id, pass: true, reason: 'ok' }) | |
| 6 | +const failed = (id) => ({ id, pass: false, reason: 'not done' }) | |
| 7 | + | |
| 8 | +describe('验收格式校验', () => { | |
| 9 | + it('全部通过只能 accept / select_winner', () => { | |
| 10 | + expect(reviewViolation({ action: 'accept', checks: [pass('c1'), pass('c2')], item_keys })).toBeNull() | |
| 11 | + expect(reviewViolation({ action: 'select_winner', checks: [pass('c1'), pass('c2')], item_keys })).toBeNull() | |
| 12 | + expect(reviewViolation({ action: 'reject', checks: [pass('c1'), pass('c2')], item_keys })).toMatch(/at least one failed/) | |
| 13 | + }) | |
| 14 | + | |
| 15 | + it('任一条不通过只能 reject', () => { | |
| 16 | + expect(reviewViolation({ action: 'reject', checks: [pass('c1'), failed('c2')], item_keys })).toBeNull() | |
| 17 | + expect(reviewViolation({ action: 'accept', checks: [pass('c1'), failed('c2')], item_keys })).toMatch(/reject instead/) | |
| 18 | + expect(reviewViolation({ action: 'select_winner', checks: [failed('c1'), pass('c2')], item_keys })).toMatch(/reject instead/) | |
| 19 | + }) | |
| 20 | + | |
| 21 | + it('必须恰好覆盖全部清单项', () => { | |
| 22 | + expect(reviewViolation({ action: 'accept', checks: [pass('c1')], item_keys })).toMatch(/missing: c2/) | |
| 23 | + expect(reviewViolation({ action: 'accept', checks: [pass('c1'), pass('c2'), pass('c9')], item_keys })).toMatch(/unknown acceptance items: c9/) | |
| 24 | + expect(reviewViolation({ action: 'accept', checks: [pass('c1'), pass('c1'), pass('c2')], item_keys })).toMatch(/more than once/) | |
| 25 | + }) | |
| 26 | + | |
| 27 | + it('未知动作直接 throw', () => { | |
| 28 | + expect(() => reviewViolation({ action: 'approve', checks: [pass('c1'), pass('c2')], item_keys })).toThrow(/Unhandled review action: approve/) | |
| 29 | + }) | |
| 30 | +}) | ... | ... |
packages/voko-exchange/test/split-relay.test.js
0 → 100644
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { splitRelayPayout } from '../src/split-relay.js' | |
| 3 | + | |
| 4 | +const hold = (agent_id, end_reason, has_valid_checkpoint) => ({ agent_id, end_reason, has_valid_checkpoint }) | |
| 5 | + | |
| 6 | +describe('SM-02 splitRelayPayout', () => { | |
| 7 | + it('1 人:全额归提交者', () => { | |
| 8 | + expect(splitRelayPayout(100, [hold('b', 'submitted', true)], 'b')).toEqual([{ agent_id: 'b', amount: 100, kind: 'winner' }]) | |
| 9 | + }) | |
| 10 | + | |
| 11 | + it('2 人平分', () => { | |
| 12 | + expect(splitRelayPayout(100, [hold('a', 'released', true), hold('b', 'submitted', false)], 'b')).toEqual([ | |
| 13 | + { agent_id: 'b', amount: 50, kind: 'winner' }, | |
| 14 | + { agent_id: 'a', amount: 50, kind: 'relay_share' }, | |
| 15 | + ]) | |
| 16 | + }) | |
| 17 | + | |
| 18 | + it('3 人除不尽:余数归最终提交者', () => { | |
| 19 | + const payouts = splitRelayPayout(100, [hold('a', 'timeout', true), hold('c', 'transferred', true), hold('b', 'submitted', true)], 'b') | |
| 20 | + expect(payouts).toEqual([ | |
| 21 | + { agent_id: 'b', amount: 34, kind: 'winner' }, | |
| 22 | + { agent_id: 'a', amount: 33, kind: 'relay_share' }, | |
| 23 | + { agent_id: 'c', amount: 33, kind: 'relay_share' }, | |
| 24 | + ]) | |
| 25 | + }) | |
| 26 | + | |
| 27 | + it('同一 Agent 多次持有只算一份;没留有效检查点的前任不参与', () => { | |
| 28 | + const holds = [hold('a', 'released', true), hold('a', 'timeout', true), hold('d', 'released', false), hold('b', 'released', true), hold('b', 'submitted', true)] | |
| 29 | + expect(splitRelayPayout(90, holds, 'b')).toEqual([ | |
| 30 | + { agent_id: 'b', amount: 45, kind: 'winner' }, | |
| 31 | + { agent_id: 'a', amount: 45, kind: 'relay_share' }, | |
| 32 | + ]) | |
| 33 | + }) | |
| 34 | + | |
| 35 | + it('各笔之和恒等于 reward(含赏金小于人数)', () => { | |
| 36 | + const holds = [hold('a', 'released', true), hold('c', 'released', true), hold('b', 'submitted', true)] | |
| 37 | + for (const reward of [1, 2, 3, 4, 7, 100, 101]) { | |
| 38 | + const payouts = splitRelayPayout(reward, holds, 'b') | |
| 39 | + expect(payouts.reduce((sum, p) => sum + p.amount, 0)).toBe(reward) | |
| 40 | + expect(payouts.every((p) => p.amount > 0)).toBe(true) | |
| 41 | + } | |
| 42 | + expect(splitRelayPayout(2, holds, 'b')).toEqual([{ agent_id: 'b', amount: 2, kind: 'winner' }]) | |
| 43 | + }) | |
| 44 | + | |
| 45 | + it('全部结束原因都被处理;未知原因直接 throw', () => { | |
| 46 | + for (const reason of ['submitted', 'released', 'timeout', 'transferred', null]) expect(() => splitRelayPayout(10, [hold('a', reason, true)], 'b')).not.toThrow() | |
| 47 | + expect(() => splitRelayPayout(10, [hold('a', 'vanished', true)], 'b')).toThrow(/Unhandled end_reason: vanished/) | |
| 48 | + }) | |
| 49 | +}) | ... | ... |
packages/voko-exchange/test/transition.test.js
| ... | ... | @@ -40,7 +40,7 @@ function stateFor(status, template = TEMPLATE_OF[status] ?? 'piecework', overrid |
| 40 | 40 | rework_count: status === 'rework' ? 1 : 0, |
| 41 | 41 | review_due_at: ['submitted', 'reviewing'].includes(status) ? PAST : null, |
| 42 | 42 | settled_at: status === 'settled' ? PAST : null, |
| 43 | - created_at: PAST, | |
| 43 | + created_at: new Date('2026-10-12T00:00:00.000Z'), | |
| 44 | 44 | updated_at: null, |
| 45 | 45 | ...overrides, |
| 46 | 46 | }) |
| ... | ... | @@ -167,11 +167,19 @@ describe('SM-01 副作用与平台规则', () => { |
| 167 | 167 | expect(first.task).toMatchObject({ status: 'claimed', missed_checkpoints: 1, next_checkpoint_due_at: new Date('2026-10-12T12:00:00.000Z') }) |
| 168 | 168 | const second = run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint_miss') |
| 169 | 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']) | |
| 170 | + // 夹具的截止已过:按原工期(6 小时)从现在重新计时,并重排过期 job。 | |
| 171 | + expect(effectTypes(second)).toEqual(['hold_end', 'task.released', 'task.released', 'job:open_expire']) | |
| 172 | + expect(second.task.deadline_at).toEqual(new Date('2026-10-12T18:00:00.000Z')) | |
| 171 | 173 | expect(second.effects[0].reason).toBe('timeout') |
| 172 | 174 | expect(run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint').task.missed_checkpoints).toBe(0) |
| 173 | 175 | }) |
| 174 | 176 | |
| 177 | + it('截止前主动释放:截止不变、不重排过期 job', () => { | |
| 178 | + const result = run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'release') | |
| 179 | + expect(result.task).toMatchObject({ status: 'open', deadline_at: FUTURE }) | |
| 180 | + expect(effectTypes(result)).toEqual(['hold_end', 'task.released', 'task.released']) | |
| 181 | + }) | |
| 182 | + | |
| 175 | 183 | it('未到期的 worker 动作是过时 job,返回 state_conflict', () => { |
| 176 | 184 | expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'open_expire')).toMatchObject({ ok: false, code: 'state_conflict' }) |
| 177 | 185 | expect(run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'hold_timeout')).toMatchObject({ ok: false, code: 'state_conflict' }) | ... | ... |
tools/scripts/verify-local.sh
| ... | ... | @@ -30,6 +30,6 @@ curl -sf http://localhost:9201/healthz >/dev/null || { echo "API 未启动,日 |
| 30 | 30 | VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ |
| 31 | 31 | pnpm --filter @voko/server verify:skeleton |
| 32 | 32 | export VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 |
| 33 | -for scenario in f0 f1 f2 concurrency timers; do | |
| 33 | +for scenario in f0 f1 f2 f3 concurrency timers; do | |
| 34 | 34 | pnpm --filter @voko/server "verify:$scenario" |
| 35 | 35 | done | ... | ... |