Commit 1d74085c5e876ecb590b9fc051e3f6618db1e569

Authored by yibo
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>
apps/voko-server/package.json
@@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
16 "verify:f0": "node tools/scenarios/voko-alpha/f0-onboarding.mjs", 16 "verify:f0": "node tools/scenarios/voko-alpha/f0-onboarding.mjs",
17 "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs", 17 "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs",
18 "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs", 18 "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs",
  19 + "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs",
19 "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs", 20 "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs",
20 "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs", 21 "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs",
21 "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs" 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,12 +11,18 @@ const ListQuery = v.object({
11 const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) }) 11 const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) })
12 const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) }) 12 const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) })
13 const ClaimParams = v.object({ token: v.pipe(v.string(), v.minLength(1)) }) 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 const GuildBody = v.object({ 16 const GuildBody = v.object({
16 name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), 17 name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)),
17 slug: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-]{1,62}$/)), 18 slug: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-]{1,62}$/)),
18 lead_user_id: v.pipe(v.string(), v.minLength(1)), 19 lead_user_id: v.pipe(v.string(), v.minLength(1)),
19 require_lead_approval: v.boolean(), 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,7 +64,7 @@ export function registerAccessRoutes(app, services) {
58 const user = await requireUser(request, services) 64 const user = await requireUser(request, services)
59 const params = v.parse(ClaimParams, request.params) 65 const params = v.parse(ClaimParams, request.params)
60 const body = v.parse(ClaimBody, request.body) 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 if (!result.ok) throw codedError(result.message, result.code) 68 if (!result.ok) throw codedError(result.message, result.code)
63 return { agent: agentView(result.agent) } 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,6 +5,9 @@ import { agentView, IdempotencyKey, PublishBody, REFUSAL_STATUS, taskView } from
5 5
6 const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) 6 const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
7 const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) 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 const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) 11 const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) })
9 12
10 /** 13 /**
@@ -80,4 +83,41 @@ export function registerOwnerRoutes(app, services) { @@ -80,4 +83,41 @@ export function registerOwnerRoutes(app, services) {
80 if (!result.ok) return reply.code(REFUSAL_STATUS[result.code]).send({ error: { code: result.code, message: result.hint, retryable: false } }) 83 if (!result.ok) return reply.code(REFUSAL_STATUS[result.code]).send({ error: { code: result.code, message: result.hint, retryable: false } })
81 return { task: taskView(result.task) } 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,6 +33,24 @@ const ListQuery = v.object({
33 cursor: v.optional(v.pipe(v.string(), v.regex(/^[^|]+\|[^|]+$/))), 33 cursor: v.optional(v.pipe(v.string(), v.regex(/^[^|]+\|[^|]+$/))),
34 limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50'), 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 const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) 54 const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) })
37 const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) 55 const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') })
38 export const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100)) 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,7 +173,13 @@ export function registerV1Routes(app, services) {
155 const params = v.parse(TaskParams, request.params) 173 const params = v.parse(TaskParams, request.params)
156 const result = await tasks.get(request.ctx, { agent: agentOf(request), task_id: params.id }) 174 const result = await tasks.get(request.ctx, { agent: agentOf(request), task_id: params.id })
157 if (!result.ok) return refused(reply, result) 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 v1.patch('/tasks/:id', async (request, reply) => { 185 v1.patch('/tasks/:id', async (request, reply) => {
@@ -165,13 +189,52 @@ export function registerV1Routes(app, services) { @@ -165,13 +189,52 @@ export function registerV1Routes(app, services) {
165 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 })) 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 v1.post(`/tasks/:id/${type}`, async (request, reply) => { 193 v1.post(`/tasks/:id/${type}`, async (request, reply) => {
170 const params = v.parse(TaskParams, request.params) 194 const params = v.parse(TaskParams, request.params)
171 return acted(reply, await tasks.act(request.ctx, { agent: agentOf(request), task_id: params.id, action: { type } })) 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 v1.get('/tasks/:id/comments', async (request, reply) => { 238 v1.get('/tasks/:id/comments', async (request, reply) => {
176 const params = v.parse(TaskParams, request.params) 239 const params = v.parse(TaskParams, request.params)
177 const result = await tasks.listComments(request.ctx, { agent: agentOf(request), task_id: params.id }) 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,20 +117,21 @@ function createWorld(name, db) {
117 return owner 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 check(`建公会 ${tag}`, guild.status === 200, guild) 123 check(`建公会 ${tag}`, guild.status === 200, guild)
123 guildIds.push(guild.json.guild.id) 124 guildIds.push(guild.json.guild.id)
124 return guild.json.guild.id 125 return guild.json.guild.id
125 } 126 }
126 127
127 /** 注册一个 Agent 并由主人认领进公会;返回 { id, key }。 */ 128 /** 注册一个 Agent 并由主人认领进公会;返回 { id, key }。 */
128 - async function createAgent(tag, owner, guildId) { 129 + async function createAgent(tag, owner, guildId, role = 'member') {
129 const registered = await call('POST', '/v1/agents/register', { body: { name: `${name}-${tag}-${run}`, skills: ['writing'] } }) 130 const registered = await call('POST', '/v1/agents/register', { body: { name: `${name}-${tag}-${run}`, skills: ['writing'] } })
130 check(`Agent ${tag}:注册`, registered.status === 200, registered) 131 check(`Agent ${tag}:注册`, registered.status === 200, registered)
131 const data = registered.json.data 132 const data = registered.json.data
132 agentIds.push(data.agent_id) 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 check(`Agent ${tag}:认领`, claimed.status === 200 && claimed.json.agent.status === 'active', claimed) 135 check(`Agent ${tag}:认领`, claimed.status === 200 && claimed.json.agent.status === 'active', claimed)
135 return { id: data.agent_id, key: data.api_key } 136 return { id: data.agent_id, key: data.api_key }
136 } 137 }
@@ -154,7 +155,8 @@ function createWorld(name, db) { @@ -154,7 +155,8 @@ function createWorld(name, db) {
154 const taskIds = guildIds.length === 0 ? [] : (await db('vk_tasks').whereIn('guild_id', guildIds).select('id')).map((row) => row.id) 155 const taskIds = guildIds.length === 0 ? [] : (await db('vk_tasks').whereIn('guild_id', guildIds).select('id')).map((row) => row.id)
155 const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) 156 const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id)
156 if (taskIds.length > 0) { 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 await db('vk_jobs').whereIn('ref_id', taskIds).del() 160 await db('vk_jobs').whereIn('ref_id', taskIds).del()
159 await db('vk_tasks').whereIn('id', taskIds).del() 161 await db('vk_tasks').whereIn('id', taskIds).del()
160 } 162 }
@@ -181,5 +183,26 @@ function createWorld(name, db) { @@ -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 #!/usr/bin/env node 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 import { randomUUID } from 'node:crypto' 5 import { randomUUID } from 'node:crypto'
6 6
7 import { runScenario } from './_lib.mjs' 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 const alice = await createOwner('alice') 10 const alice = await createOwner('alice')
11 const bob = await createOwner('bob') 11 const bob = await createOwner('bob')
12 const guild = await createGuild('main', alice.user_id) 12 const guild = await createGuild('main', alice.user_id)
@@ -81,4 +81,27 @@ await runScenario(&#39;f1&#39;, async ({ db, check, call, createOwner, createGuild, crea @@ -81,4 +81,27 @@ await runScenario(&#39;f1&#39;, async ({ db, check, call, createOwner, createGuild, crea
81 const entered = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: member.key }) 81 const entered = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: member.key })
82 const cancelContest = await call('POST', `/v1/tasks/${contest.id}/cancel`, { token: publisher.key }) 82 const cancelContest = await call('POST', `/v1/tasks/${contest.id}/cancel`, { token: publisher.key })
83 check('F1-07 有人报名后撤回 → 409', entered.status === 200 && entered.json.data.status === 'in_contest' && cancelContest.status === 409, { entered, cancelContest }) 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 #!/usr/bin/env node 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 import { runScenario } from './_lib.mjs' 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 const alice = await createOwner('alice') 8 const alice = await createOwner('alice')
9 const bob = await createOwner('bob') 9 const bob = await createOwner('bob')
10 const guild = await createGuild('main', alice.user_id) 10 const guild = await createGuild('main', alice.user_id)
@@ -47,4 +47,58 @@ await runScenario(&#39;f2&#39;, async ({ check, call, createOwner, createGuild, createAg @@ -47,4 +47,58 @@ await runScenario(&#39;f2&#39;, async ({ check, call, createOwner, createGuild, createAg
47 const again = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key }) 47 const again = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key })
48 const ownContest = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: sibling.key }) 48 const ownContest = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: sibling.key })
49 check('重复报名、同主人报名 → 409', again.status === 409 && ownContest.status === 409, { again, ownContest }) 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 #!/usr/bin/env node 1 #!/usr/bin/env node
2 /** 2 /**
3 * 时间类规则的真实链路脚本,由真实 voko-worker 触发(运行前须已启动 `pnpm --filter @voko/server worker`)。 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 import { runScenario } from './_lib.mjs' 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 const alice = await createOwner('alice') 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,6 +31,11 @@ export async function api(method, path, body, idempotencyKey) {
31 }, 31 },
32 body: body === undefined ? undefined : JSON.stringify(body), 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 const json = await response.json() 39 const json = await response.json()
35 if (!response.ok) throw new Error(json.error.message) 40 if (!response.ok) throw new Error(json.error.message)
36 return json 41 return json
apps/voko-web/src/i18n/messages.js
@@ -42,6 +42,7 @@ export const messages = { @@ -42,6 +42,7 @@ export const messages = {
42 guild: 'Guild', 42 guild: 'Guild',
43 pickGuild: 'Choose a guild', 43 pickGuild: 'Choose a guild',
44 submit: 'Claim agent', 44 submit: 'Claim agent',
  45 + asCompany: 'Claim as the guild\'s company agent (guild lead only)',
45 done: 'Claimed. {name} is now active and received 100 starter points.', 46 done: 'Claimed. {name} is now active and received 100 starter points.',
46 toAgents: 'Go to my agents', 47 toAgents: 'Go to my agents',
47 }, 48 },
@@ -51,6 +52,7 @@ export const messages = { @@ -51,6 +52,7 @@ export const messages = {
51 level: 'Level {level}', 52 level: 'Level {level}',
52 wallet: 'Available {available} · Held {held}', 53 wallet: 'Available {available} · Held {held}',
53 board: 'Guild board', 54 board: 'Guild board',
  55 + approvals: 'Tasks waiting for my approval',
54 resetKey: 'Reset API key', 56 resetKey: 'Reset API key',
55 newKey: 'New API key (shown once, give it to your agent):', 57 newKey: 'New API key (shown once, give it to your agent):',
56 suspend: 'Suspend', 58 suspend: 'Suspend',
@@ -60,6 +62,13 @@ export const messages = { @@ -60,6 +62,13 @@ export const messages = {
60 saved: 'Saved.', 62 saved: 'Saved.',
61 status: { pending_claim: 'Pending claim', active: 'Active', suspended: 'Suspended' }, 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 board: { 72 board: {
64 title: 'Guild board', 73 title: 'Guild board',
65 back: 'Back to my agents', 74 back: 'Back to my agents',
@@ -124,6 +133,7 @@ export const messages = { @@ -124,6 +133,7 @@ export const messages = {
124 guild: '公会', 133 guild: '公会',
125 pickGuild: '选择公会', 134 pickGuild: '选择公会',
126 submit: '认领', 135 submit: '认领',
  136 + asCompany: '认领为公会的公司 Agent(仅公会负责人)',
127 done: '认领成功。{name} 已激活,并收到 100 启动积分。', 137 done: '认领成功。{name} 已激活,并收到 100 启动积分。',
128 toAgents: '去我的 Agent', 138 toAgents: '去我的 Agent',
129 }, 139 },
@@ -133,6 +143,7 @@ export const messages = { @@ -133,6 +143,7 @@ export const messages = {
133 level: '等级 {level}', 143 level: '等级 {level}',
134 wallet: '可用 {available} · 托管中 {held}', 144 wallet: '可用 {available} · 托管中 {held}',
135 board: '公会看板', 145 board: '公会看板',
  146 + approvals: '待我审核的任务',
136 resetKey: '重置 API key', 147 resetKey: '重置 API key',
137 newKey: '新的 API key(只显示一次,请交给你的 Agent):', 148 newKey: '新的 API key(只显示一次,请交给你的 Agent):',
138 suspend: '停用', 149 suspend: '停用',
@@ -142,6 +153,13 @@ export const messages = { @@ -142,6 +153,13 @@ export const messages = {
142 saved: '已保存。', 153 saved: '已保存。',
143 status: { pending_claim: '待认领', active: '运行中', suspended: '已停用' }, 154 status: { pending_claim: '待认领', active: '运行中', suspended: '已停用' },
144 }, 155 },
  156 + approvals: {
  157 + title: '待审核任务',
  158 + intro: '你负责的公会里由公司 Agent 发布的任务。通过后上板,驳回则退回托管积分。',
  159 + empty: '没有等待你审核的任务。',
  160 + approve: '通过',
  161 + reject: '驳回并退款',
  162 + },
145 board: { 163 board: {
146 title: '公会看板', 164 title: '公会看板',
147 back: '返回我的 Agent', 165 back: '返回我的 Agent',
apps/voko-web/src/main.js
@@ -7,6 +7,7 @@ import App from &#39;./App.vue&#39; @@ -7,6 +7,7 @@ import App from &#39;./App.vue&#39;
7 import { messages } from './i18n/messages.js' 7 import { messages } from './i18n/messages.js'
8 import AdminPage from './pages/AdminPage.vue' 8 import AdminPage from './pages/AdminPage.vue'
9 import AgentsPage from './pages/AgentsPage.vue' 9 import AgentsPage from './pages/AgentsPage.vue'
  10 +import ApprovalsPage from './pages/ApprovalsPage.vue'
10 import BoardPage from './pages/BoardPage.vue' 11 import BoardPage from './pages/BoardPage.vue'
11 import ClaimPage from './pages/ClaimPage.vue' 12 import ClaimPage from './pages/ClaimPage.vue'
12 import HomePage from './pages/HomePage.vue' 13 import HomePage from './pages/HomePage.vue'
@@ -20,6 +21,7 @@ const router = createRouter({ @@ -20,6 +21,7 @@ const router = createRouter({
20 { path: '/admin', component: AdminPage }, 21 { path: '/admin', component: AdminPage },
21 { path: '/claim/:token', component: ClaimPage }, 22 { path: '/claim/:token', component: ClaimPage },
22 { path: '/agents', component: AgentsPage }, 23 { path: '/agents', component: AgentsPage },
  24 + { path: '/approvals', component: ApprovalsPage },
23 { path: '/agents/:id/board', component: BoardPage }, 25 { path: '/agents/:id/board', component: BoardPage },
24 ], 26 ],
25 }) 27 })
apps/voko-web/src/pages/AgentsPage.vue
@@ -66,6 +66,7 @@ onMounted(async () =&gt; { @@ -66,6 +66,7 @@ onMounted(async () =&gt; {
66 66
67 <template> 67 <template>
68 <h1>{{ t('agents.title') }}</h1> 68 <h1>{{ t('agents.title') }}</h1>
  69 + <p><router-link to="/approvals">{{ t('agents.approvals') }}</router-link></p>
69 <template v-if="agents"> 70 <template v-if="agents">
70 <p v-if="agents.length === 0">{{ t('agents.empty') }}</p> 71 <p v-if="agents.length === 0">{{ t('agents.empty') }}</p>
71 <Card v-for="agent in agents" :key="agent.id" class="voko-agent"> 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,13 +12,14 @@ const router = useRouter()
12 const guilds = ref([]) 12 const guilds = ref([])
13 const guildId = ref('') 13 const guildId = ref('')
14 const code = ref('') 14 const code = ref('')
  15 +const asCompany = ref(false)
15 const error = ref('') 16 const error = ref('')
16 const claimed = ref(null) 17 const claimed = ref(null)
17 18
18 async function claim() { 19 async function claim() {
19 error.value = '' 20 error.value = ''
20 try { 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 } catch (err) { 23 } catch (err) {
23 error.value = err instanceof Error ? err.message : String(err) 24 error.value = err instanceof Error ? err.message : String(err)
24 } 25 }
@@ -47,8 +48,16 @@ onMounted(async () =&gt; { @@ -47,8 +48,16 @@ onMounted(async () =&gt; {
47 <span>{{ t('claim.guild') }}</span> 48 <span>{{ t('claim.guild') }}</span>
48 <Select v-model="guildId" :options="guilds" :placeholder="t('claim.pickGuild')" :aria-label="t('claim.guild')" block /> 49 <Select v-model="guildId" :options="guilds" :placeholder="t('claim.pickGuild')" :aria-label="t('claim.guild')" block />
49 </div> 50 </div>
  51 + <label class="voko-check"><input v-model="asCompany" type="checkbox"> {{ t('claim.asCompany') }}</label>
50 <p v-if="error" role="alert">{{ error }}</p> 52 <p v-if="error" role="alert">{{ error }}</p>
51 <Button :disabled="code.trim() === '' || guildId === ''" @click="claim">{{ t('claim.submit') }}</Button> 53 <Button :disabled="code.trim() === '' || guildId === ''" @click="claim">{{ t('claim.submit') }}</Button>
52 </template> 54 </template>
53 </Card> 55 </Card>
54 </template> 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,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 - `apps/voko-server/.env` 配置了真实 SMTP;`VOKO_TEST_EMAIL` 是能收信的邮箱;有一个管理员账号(本地 `pnpm dev:user admin@example.test admin123`)。 8 - `apps/voko-server/.env` 配置了真实 SMTP;`VOKO_TEST_EMAIL` 是能收信的邮箱;有一个管理员账号(本地 `pnpm dev:user admin@example.test admin123`)。
9 - 每个脚本会给测试邮箱的 plus 地址发若干封验证码与审核通过邮件。 9 - 每个脚本会给测试邮箱的 plus 地址发若干封验证码与审核通过邮件。
10 10
@@ -13,10 +13,11 @@ @@ -13,10 +13,11 @@
13 ```bash 13 ```bash
14 export VOKO_TEST_EMAIL=you@gmail.com VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 14 export VOKO_TEST_EMAIL=you@gmail.com VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123
15 pnpm --filter @voko/server verify:f0 # F0-01–05 入驻,见 verify-f0.md 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 pnpm --filter @voko/server verify:concurrency # CC-01 并发认领、CC-02 发布幂等 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 pnpm --filter @voko/server verify:conservation # 只跑对账 21 pnpm --filter @voko/server verify:conservation # 只跑对账
21 ``` 22 ```
22 23
@@ -30,8 +31,10 @@ pnpm --filter @voko/server verify:conservation # 只跑对账 @@ -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,10 +67,10 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) {
67 * 主人认领 Agent:核对验证码 → 计数 +1(≤3)→ Agent 激活并加入公会 → 发启动积分,全部在一个事务里。 67 * 主人认领 Agent:核对验证码 → 计数 +1(≤3)→ Agent 激活并加入公会 → 发启动积分,全部在一个事务里。
68 * 同一主人重放已成功的认领返回同一结果,不重复计数与发放。 68 * 同一主人重放已成功的认领返回同一结果,不重复计数与发放。
69 * @param {object} ctx 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 * @returns {Promise<{ ok: true, agent: import('@voko/exchange').Agent } | { ok: false, code: 'NOT_FOUND'|'FORBIDDEN'|'CONFLICT'|'CODE_INVALID', message: string }>} 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 const now = new Date() 74 const now = new Date()
75 return db.transaction(async (trx) => { 75 return db.transaction(async (trx) => {
76 const claimRow = await trx('vk_agent_claims').where({ claim_token_hash: sha256(claim_token) }).forUpdate().first() 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,12 +94,24 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) {
94 throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`) 94 throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`)
95 } 95 }
96 if (claimRow.verification_code !== verification_code) return refuse('CODE_INVALID', 'Verification code does not match') 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 if (request.claimed_agent_count >= MAX_AGENTS_PER_OWNER) return refuse('CONFLICT', `You can claim at most ${MAX_AGENTS_PER_OWNER} agents`) 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 await trx('vk_access_requests').where({ id: request.id }).increment('claimed_agent_count', 1) 112 await trx('vk_access_requests').where({ id: request.id }).increment('claimed_agent_count', 1)
101 await trx('vk_agent_claims').where({ id: claimRow.id }).update({ claimed_by_user_id: user_id, claimed_at: now }) 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 await trx('vk_guild_members').insert({ id: `gm_${randomUUID()}`, guild_id, user_id, role: 'member', created_at: now }).onConflict(['guild_id', 'user_id']).ignore() 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 const agent = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) 116 const agent = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id })
105 await grantStarter(ctx, trx, { agent_id: agent.id, wallet_user_id: agent.wallet_user_id }) 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 = &#39;vk_escrows&#39; @@ -5,9 +5,10 @@ const ESCROWS = &#39;vk_escrows&#39;
5 const PAYOUTS = 'vk_escrow_payouts' 5 const PAYOUTS = 'vk_escrow_payouts'
6 const SOURCE = 'voko' 6 const SOURCE = 'voko'
7 const REF_TYPE = 'vk_task' 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 * 所有方法都在调用方的事务(`trx`)里动账,业务表与账本一起提交或回滚;幂等键由任务唯一决定。 12 * 所有方法都在调用方的事务(`trx`)里动账,业务表与账本一起提交或回滚;幂等键由任务唯一决定。
12 * 余额不足时账本抛 `BillingError(INSUFFICIENT_BALANCE)`,整个事务回滚。 13 * 余额不足时账本抛 `BillingError(INSUFFICIENT_BALANCE)`,整个事务回滚。
13 * 14 *
@@ -74,6 +75,35 @@ export function createEscrow({ db }) { @@ -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 * 钱包视图:可用 = 账本余额;冻结 = 该钱包名下 held 托管之和。 107 * 钱包视图:可用 = 账本余额;冻结 = 该钱包名下 held 托管之和。
78 * @param {object} _ctx 108 * @param {object} _ctx
79 * @param {{ wallet_user_id: string }} input 109 * @param {{ wallet_user_id: string }} input
@@ -100,7 +130,7 @@ export function createEscrow({ db }) { @@ -100,7 +130,7 @@ export function createEscrow({ db }) {
100 return -Number(row.spent) 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
1 /** 1 /**
2 * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 2 * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。
3 * 3 *
4 - * 当前有托管的冻结、补扣与退回;结算(含接力分配)、打赏与对账在 W4 加入。 4 + * 当前有托管的冻结、补扣、结算与退回;打赏在 W4 加入。
5 * 5 *
6 * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 6 * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。
7 */ 7 */
packages/voko-exchange/src/agents.js
@@ -110,10 +110,10 @@ export function createAgents() { @@ -110,10 +110,10 @@ export function createAgents() {
110 * 认领:pending_claim → active。行不在 pending_claim 即 throw(调用方已锁认领凭证,不应发生)。 110 * 认领:pending_claim → active。行不在 pending_claim 即 throw(调用方已锁认领凭证,不应发生)。
111 * @param {object} _ctx 111 * @param {object} _ctx
112 * @param {import('knex').Knex} runner 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 if (updated !== 1) throw new Error(`vk_agents activate expected pending_claim row, id: ${agent_id}`) 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,11 +15,11 @@ export function createGuilds({ db }) {
15 /** 15 /**
16 * 建公会并把负责人登记为成员。slug 已被占用返回 CONFLICT(由 UNIQUE 约束裁决)。 16 * 建公会并把负责人登记为成员。slug 已被占用返回 CONFLICT(由 UNIQUE 约束裁决)。
17 * @param {object} _ctx 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 * @returns {Promise<{ ok: true, guild: Guild } | { ok: false, code: 'CONFLICT' }>} 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 return db.transaction(async (trx) => { 23 return db.transaction(async (trx) => {
24 // slug 冲突由 UNIQUE 裁决:被忽略的插入读不回本次 id。 24 // slug 冲突由 UNIQUE 裁决:被忽略的插入读不回本次 id。
25 await trx(TABLE).insert(guild).onConflict('slug').ignore() 25 await trx(TABLE).insert(guild).onConflict('slug').ignore()
packages/voko-exchange/src/jobs.js
@@ -17,6 +17,7 @@ const WARN_KINDS = Object.freeze([&#39;checkpoint_warn&#39;, &#39;review_warn&#39;]) @@ -17,6 +17,7 @@ const WARN_KINDS = Object.freeze([&#39;checkpoint_warn&#39;, &#39;review_warn&#39;])
17 * tasks: { 17 * tasks: {
18 * actAsWorker(ctx: object, input: { task_id: string, action: any }): Promise<{ ok: boolean }>, 18 * actAsWorker(ctx: object, input: { task_id: string, action: any }): Promise<{ ok: boolean }>,
19 * warn(ctx: object, input: { task_id: string, kind: any }): Promise<void>, 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 * logger: { error(message: string, fields: object): void }, 22 * logger: { error(message: string, fields: object): void },
22 * }} deps 23 * }} deps
@@ -68,13 +69,19 @@ export function createJobs({ db, tasks, logger }) { @@ -68,13 +69,19 @@ export function createJobs({ db, tasks, logger }) {
68 * @param {{ id: string, kind: string, ref_id: string|null }} job 69 * @param {{ id: string, kind: string, ref_id: string|null }} job
69 */ 70 */
70 async function execute(ctx, job) { 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 throw new Error(`Unhandled job kind: ${job.kind}, id: ${job.id}`) 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 import { randomUUID } from 'node:crypto' 1 import { randomUUID } from 'node:crypto'
2 2
3 import { maxRarityForLevel, rarityForReward } from './rarity.js' 3 import { maxRarityForLevel, rarityForReward } from './rarity.js'
  4 +import { reviewViolation } from './review.js'
  5 +import { splitRelayPayout } from './split-relay.js'
4 import { taskFromRow } from './task.js' 6 import { taskFromRow } from './task.js'
5 import { publishStatus, transition } from './transition.js' 7 import { publishStatus, transition } from './transition.js'
6 8
@@ -9,6 +11,11 @@ const ITEMS = &#39;vk_task_acceptance_items&#39; @@ -9,6 +11,11 @@ const ITEMS = &#39;vk_task_acceptance_items&#39;
9 const HOLDS = 'vk_task_holds' 11 const HOLDS = 'vk_task_holds'
10 const ENTRIES = 'vk_task_entries' 12 const ENTRIES = 'vk_task_entries'
11 const COMMENTS = 'vk_comments' 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 const EVENTS = 'vk_events' 19 const EVENTS = 'vk_events'
13 const JOBS = 'vk_jobs' 20 const JOBS = 'vk_jobs'
14 21
@@ -30,6 +37,7 @@ const JOBS = &#39;vk_jobs&#39; @@ -30,6 +37,7 @@ const JOBS = &#39;vk_jobs&#39;
30 * topupExists(ctx: object, trx: import('knex').Knex, input: { task_id: string, idempotency_key: string }): Promise<boolean>, 37 * topupExists(ctx: object, trx: import('knex').Knex, input: { task_id: string, idempotency_key: string }): Promise<boolean>,
31 * topup(ctx: object, trx: import('knex').Knex, input: { task_id: string, amount: number, idempotency_key: string }): Promise<void>, 38 * topup(ctx: object, trx: import('knex').Knex, input: { task_id: string, amount: number, idempotency_key: string }): Promise<void>,
32 * refund(ctx: object, trx: import('knex').Knex, input: { task_id: string }): Promise<void>, 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 * spentToday(ctx: object, trx: import('knex').Knex, input: { wallet_user_id: string, now: Date }): Promise<number>, 41 * spentToday(ctx: object, trx: import('knex').Knex, input: { wallet_user_id: string, now: Date }): Promise<number>,
34 * }, 42 * },
35 * }} deps 43 * }} deps
@@ -83,16 +91,25 @@ export function createTasks({ db, agents, escrow }) { @@ -83,16 +91,25 @@ export function createTasks({ db, agents, escrow }) {
83 91
84 /** 92 /**
85 * Agent 对任务做一个动作。`idempotency_key` 只用于 edit 的补扣托管。 93 * Agent 对任务做一个动作。`idempotency_key` 只用于 edit 的补扣托管。
  94 + * `prepare` 在锁住任务后、跑状态机前执行(读其它表做校验,或据此给出动作);`record` 在状态与副作用写完后执行(写检查点、提交、验收等记录)。
  95 + * @template R
86 * @param {object} ctx 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 return db.transaction(async (trx) => { 108 return db.transaction(async (trx) => {
92 const task = await lockTask(trx, task_id) 109 const task = await lockTask(trx, task_id)
93 const hidden = task === null ? refuse('not_found', `Task not found: ${task_id}`) : hiddenFrom(agent, task) 110 const hidden = task === null ? refuse('not_found', `Task not found: ${task_id}`) : hiddenFrom(agent, task)
94 if (task === null || hidden !== null) return /** @type {Refusal} */ (hidden) 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 // 锁 Agent 行:同一 Agent 并发认领不同任务时,持有数的读取与新增串行。 113 // 锁 Agent 行:同一 Agent 并发认领不同任务时,持有数的读取与新增串行。
97 await trx('vk_agents').where({ id: agent.id }).forUpdate().first('id') 114 await trx('vk_agents').where({ id: agent.id }).forUpdate().first('id')
98 const [holds, entry] = await Promise.all([ 115 const [holds, entry] = await Promise.all([
@@ -108,7 +125,12 @@ export function createTasks({ db, agents, escrow }) { @@ -108,7 +125,12 @@ export function createTasks({ db, agents, escrow }) {
108 active_hold_count: Number(holds.n), 125 active_hold_count: Number(holds.n),
109 is_entrant: entry !== undefined, 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,7 +144,7 @@ export function createTasks({ db, agents, escrow }) {
122 return db.transaction(async (trx) => { 144 return db.transaction(async (trx) => {
123 const task = await lockTask(trx, task_id) 145 const task = await lockTask(trx, task_id)
124 if (task === null) return refuse('not_found', `Task not found: ${task_id}`) 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,10 +155,11 @@ export function createTasks({ db, agents, escrow }) {
133 * @param {import('./transition.js').Action} action 155 * @param {import('./transition.js').Action} action
134 * @param {import('./transition.js').Actor} actor 156 * @param {import('./transition.js').Actor} actor
135 * @param {string|undefined} idempotency_key 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 const guild = await guildRow(trx, task.guild_id) 163 const guild = await guildRow(trx, task.guild_id)
141 const publisher = await agents.getById(ctx, trx, { agent_id: task.publisher_agent_id }) 164 const publisher = await agents.getById(ctx, trx, { agent_id: task.publisher_agent_id })
142 const result = transition({ task, guild, publisher_owner_user_id: /** @type {string} */ (publisher.owner_user_id) }, action, actor, now) 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,14 +205,46 @@ export function createTasks({ db, agents, escrow }) {
182 await insertJob(trx, task.id, effect.kind, effect.run_at, now) 205 await insertJob(trx, task.id, effect.kind, effect.run_at, now)
183 break 206 break
184 case 'escrow_settle': 207 case 'escrow_settle':
  208 + await settle(ctx, trx, next, effect.winner_agent_id)
  209 + break
185 case 'dispute_open': 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 default: 213 default:
189 throw new Error(`Unhandled effect: ${/** @type {any} */ (effect).type}, id: ${task.id}`) 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,7 +275,7 @@ export function createTasks({ db, agents, escrow }) {
220 /** 275 /**
221 * @param {object} _ctx 276 * @param {object} _ctx
222 * @param {{ agent: Agent, task_id: string }} input 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 async function get(_ctx, { agent, task_id }) { 280 async function get(_ctx, { agent, task_id }) {
226 const row = await db(TASKS).where({ id: task_id }).first() 281 const row = await db(TASKS).where({ id: task_id }).first()
@@ -228,8 +283,15 @@ export function createTasks({ db, agents, escrow }) { @@ -228,8 +283,15 @@ export function createTasks({ db, agents, escrow }) {
228 const task = taskFromRow(row) 283 const task = taskFromRow(row)
229 const hidden = hiddenFrom(agent, task) 284 const hidden = hiddenFrom(agent, task)
230 if (hidden !== null) return hidden 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,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 * @param {object} _ctx 568 * @param {object} _ctx
294 * @param {{ task_id: string, kind: 'checkpoint_warn'|'review_warn' }} input 569 * @param {{ task_id: string, kind: 'checkpoint_warn'|'review_warn' }} input
@@ -310,7 +585,7 @@ export function createTasks({ db, agents, escrow }) { @@ -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,7 +615,7 @@ async function lockTask(trx, task_id) {
340 * @param {string} guild_id 615 * @param {string} guild_id
341 */ 616 */
342 async function guildRow(runner, guild_id) { 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 if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`) 619 if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`)
345 return guild 620 return guild
346 } 621 }
packages/voko-exchange/src/transition.js
@@ -184,18 +184,18 @@ const TRANSITIONS = { @@ -184,18 +184,18 @@ const TRANSITIONS = {
184 if (actor.kind !== 'worker') return notAllowed('Only the platform can record a missed checkpoint.') 184 if (actor.kind !== 'worker') return notAllowed('Only the platform can record a missed checkpoint.')
185 const due = /** @type {Date} */ (task.next_checkpoint_due_at) 185 const due = /** @type {Date} */ (task.next_checkpoint_due_at)
186 if (now < due) return fail('state_conflict', 'Checkpoint is not overdue.') 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 return ok({ ...task, next_checkpoint_due_at: checkpointDue(state, due), missed_checkpoints: 1 }, checkpointJobs(state, due)) 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 if (!isHolder(task, actor)) return notAllowed('Only the current holder can release.') 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 hold_timeout({ task }, _action, actor, now) { 195 hold_timeout({ task }, _action, actor, now) {
196 if (actor.kind !== 'worker') return notAllowed('Only the platform can time out a hold.') 196 if (actor.kind !== 'worker') return notAllowed('Only the platform can time out a hold.')
197 if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') 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 transfer_accept(state, _action, actor, now) { 200 transfer_accept(state, _action, actor, now) {
201 const refused = takerRefusal(state, actor) 201 const refused = takerRefusal(state, actor)
@@ -371,10 +371,25 @@ function hold(state, agentId, now) { @@ -371,10 +371,25 @@ function hold(state, agentId, now) {
371 371
372 /** 372 /**
373 * @param {Task} task 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 * @returns {Task} 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,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 * @param {'released'|'timeout'} reason 422 * @param {'released'|'timeout'} reason
407 * @returns {Effect[]} 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] ?? &#39;piecework&#39;, overrid @@ -40,7 +40,7 @@ function stateFor(status, template = TEMPLATE_OF[status] ?? &#39;piecework&#39;, overrid
40 rework_count: status === 'rework' ? 1 : 0, 40 rework_count: status === 'rework' ? 1 : 0,
41 review_due_at: ['submitted', 'reviewing'].includes(status) ? PAST : null, 41 review_due_at: ['submitted', 'reviewing'].includes(status) ? PAST : null,
42 settled_at: status === 'settled' ? PAST : null, 42 settled_at: status === 'settled' ? PAST : null,
43 - created_at: PAST, 43 + created_at: new Date('2026-10-12T00:00:00.000Z'),
44 updated_at: null, 44 updated_at: null,
45 ...overrides, 45 ...overrides,
46 }) 46 })
@@ -167,11 +167,19 @@ describe(&#39;SM-01 副作用与平台规则&#39;, () =&gt; { @@ -167,11 +167,19 @@ describe(&#39;SM-01 副作用与平台规则&#39;, () =&gt; {
167 expect(first.task).toMatchObject({ status: 'claimed', missed_checkpoints: 1, next_checkpoint_due_at: new Date('2026-10-12T12:00:00.000Z') }) 167 expect(first.task).toMatchObject({ status: 'claimed', missed_checkpoints: 1, next_checkpoint_due_at: new Date('2026-10-12T12:00:00.000Z') })
168 const second = run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint_miss') 168 const second = run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint_miss')
169 expect(second.task).toMatchObject({ status: 'open', holder_agent_id: null, missed_checkpoints: 0 }) 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 expect(second.effects[0].reason).toBe('timeout') 173 expect(second.effects[0].reason).toBe('timeout')
172 expect(run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint').task.missed_checkpoints).toBe(0) 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 it('未到期的 worker 动作是过时 job,返回 state_conflict', () => { 183 it('未到期的 worker 动作是过时 job,返回 state_conflict', () => {
176 expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'open_expire')).toMatchObject({ ok: false, code: 'state_conflict' }) 184 expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'open_expire')).toMatchObject({ ok: false, code: 'state_conflict' })
177 expect(run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'hold_timeout')).toMatchObject({ ok: false, code: 'state_conflict' }) 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 &gt;/dev/null || { echo &quot;API 未启动,日 @@ -30,6 +30,6 @@ curl -sf http://localhost:9201/healthz &gt;/dev/null || { echo &quot;API 未启动,日
30 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ 30 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \
31 pnpm --filter @voko/server verify:skeleton 31 pnpm --filter @voko/server verify:skeleton
32 export VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 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 pnpm --filter @voko/server "verify:$scenario" 34 pnpm --filter @voko/server "verify:$scenario"
35 done 35 done