From 1d74085c5e876ecb590b9fc051e3f6618db1e569 Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 10 Oct 2026 14:46:12 +0800 Subject: [PATCH] feat(exchange): W3——检查点、接力、转让、提交、按清单验收、结算、仲裁裁决、发布前审核 --- apps/voko-server/package.json | 1 + apps/voko-server/src/routes/access.js | 10 ++++++++-- apps/voko-server/src/routes/owner.js | 40 ++++++++++++++++++++++++++++++++++++++++ apps/voko-server/src/routes/v1.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs | 35 +++++++++++++++++++++++++++++------ apps/voko-server/tools/scenarios/voko-alpha/f1-publish.mjs | 27 +++++++++++++++++++++++++-- apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-server/tools/scenarios/voko-alpha/timers.mjs | 54 +++++++++++++++++++++++++++++++++++++++++------------- apps/voko-web/src/api.js | 5 +++++ apps/voko-web/src/i18n/messages.js | 18 ++++++++++++++++++ apps/voko-web/src/main.js | 2 ++ apps/voko-web/src/pages/AgentsPage.vue | 1 + apps/voko-web/src/pages/ApprovalsPage.vue | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/src/pages/ClaimPage.vue | 11 ++++++++++- docs/runbooks/verify-alpha.md | 15 +++++++++------ packages/voko-access/src/agent-access.js | 20 ++++++++++++++++---- packages/voko-clearing/src/escrow.js | 34 ++++++++++++++++++++++++++++++++-- packages/voko-clearing/src/index.js | 2 +- packages/voko-exchange/src/agents.js | 6 +++--- packages/voko-exchange/src/guilds.js | 6 +++--- packages/voko-exchange/src/jobs.js | 19 +++++++++++++------ packages/voko-exchange/src/review.js | 25 +++++++++++++++++++++++++ packages/voko-exchange/src/split-relay.js | 37 +++++++++++++++++++++++++++++++++++++ packages/voko-exchange/src/tasks.js | 309 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- packages/voko-exchange/src/transition.js | 40 +++++++++++++++++++++++++++++++--------- packages/voko-exchange/test/review.test.js | 30 ++++++++++++++++++++++++++++++ packages/voko-exchange/test/split-relay.test.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ packages/voko-exchange/test/transition.test.js | 12 ++++++++++-- tools/scripts/verify-local.sh | 2 +- 30 files changed, 1023 insertions(+), 82 deletions(-) create mode 100644 apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs create mode 100644 apps/voko-web/src/pages/ApprovalsPage.vue create mode 100644 packages/voko-exchange/src/review.js create mode 100644 packages/voko-exchange/src/split-relay.js create mode 100644 packages/voko-exchange/test/review.test.js create mode 100644 packages/voko-exchange/test/split-relay.test.js diff --git a/apps/voko-server/package.json b/apps/voko-server/package.json index e7402bc..405d7b7 100644 --- a/apps/voko-server/package.json +++ b/apps/voko-server/package.json @@ -16,6 +16,7 @@ "verify:f0": "node tools/scenarios/voko-alpha/f0-onboarding.mjs", "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs", "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs", + "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs", "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs", "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs", "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs" diff --git a/apps/voko-server/src/routes/access.js b/apps/voko-server/src/routes/access.js index 9c60d02..61b6e14 100644 --- a/apps/voko-server/src/routes/access.js +++ b/apps/voko-server/src/routes/access.js @@ -11,12 +11,18 @@ const ListQuery = v.object({ const ReviewParams = v.object({ id: v.pipe(v.string(), v.minLength(1)) }) const ReviewBody = v.object({ decision: v.picklist(['approved', 'rejected']) }) const ClaimParams = v.object({ token: v.pipe(v.string(), v.minLength(1)) }) -const ClaimBody = v.object({ verification_code: v.pipe(v.string(), v.minLength(1)), guild_id: v.pipe(v.string(), v.minLength(1)) }) +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') }) +const WindowSec = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(30 * 86400)) const GuildBody = v.object({ name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)), slug: v.pipe(v.string(), v.regex(/^[a-z0-9][a-z0-9-]{1,62}$/)), lead_user_id: v.pipe(v.string(), v.minLength(1)), require_lead_approval: v.boolean(), + // 时间规则是公会级产品配置(默认 6h / 24h / 72h / 24h)。 + checkpoint_interval_sec: v.optional(WindowSec), + piece_review_window_sec: v.optional(WindowSec), + contest_review_window_sec: v.optional(WindowSec), + transfer_ttl_sec: v.optional(WindowSec), }) /** @@ -58,7 +64,7 @@ export function registerAccessRoutes(app, services) { const user = await requireUser(request, services) const params = v.parse(ClaimParams, request.params) const body = v.parse(ClaimBody, request.body) - 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 }) + 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 }) if (!result.ok) throw codedError(result.message, result.code) return { agent: agentView(result.agent) } }) diff --git a/apps/voko-server/src/routes/owner.js b/apps/voko-server/src/routes/owner.js index 77f2bb4..7d5b0f1 100644 --- a/apps/voko-server/src/routes/owner.js +++ b/apps/voko-server/src/routes/owner.js @@ -5,6 +5,9 @@ import { agentView, IdempotencyKey, PublishBody, REFUSAL_STATUS, taskView } from const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) +const TaskIdParams = v.object({ task_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) +const ApprovalBody = v.object({ decision: v.picklist(['approve', 'reject']) }) +const ResolveBody = v.object({ outcome: v.picklist(['settled', 'refunded']), winner_agent_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))) }) const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) /** @@ -80,4 +83,41 @@ export function registerOwnerRoutes(app, services) { if (!result.ok) return reply.code(REFUSAL_STATUS[result.code]).send({ error: { code: result.code, message: result.hint, retryable: false } }) return { task: taskView(result.task) } }) + + /** + * 业务拒绝 → 网页接口的错误形状。 + * @param {import('fastify').FastifyReply} reply + * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal + */ + const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) + + // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。 + app.get('/api/voko/approvals', async (request) => { + const user = await requireUser(request, services) + return { tasks: (await tasks.listPendingApprovals(request.ctx, { user_id: user.id })).map(taskView) } + }) + + app.post('/api/voko/approvals/:task_id', async (request, reply) => { + const user = await requireUser(request, services) + const params = v.parse(TaskIdParams, request.params) + const body = v.parse(ApprovalBody, request.body) + const result = await tasks.decideApproval(request.ctx, { user_id: user.id, task_id: params.task_id, decision: body.decision }) + return result.ok ? { task: taskView(result.task) } : refused(reply, result) + }) + + // 运营:仲裁队列与裁决。 + app.get('/api/voko/admin/disputes', async (request) => { + const user = await requireUser(request, services) + if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN') + 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) })) } + }) + + app.post('/api/voko/admin/disputes/:task_id/resolve', async (request, reply) => { + const user = await requireUser(request, services) + if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN') + const params = v.parse(TaskIdParams, request.params) + const body = v.parse(ResolveBody, request.body) + 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 }) + return result.ok ? { task: taskView(result.task) } : refused(reply, result) + }) } diff --git a/apps/voko-server/src/routes/v1.js b/apps/voko-server/src/routes/v1.js index 64ff39a..43e906f 100644 --- a/apps/voko-server/src/routes/v1.js +++ b/apps/voko-server/src/routes/v1.js @@ -33,6 +33,24 @@ const ListQuery = v.object({ cursor: v.optional(v.pipe(v.string(), v.regex(/^[^|]+\|[^|]+$/))), limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50'), }) +const HttpsUrl = v.pipe(v.string(), v.url(), v.startsWith('https://'), v.maxLength(2048)) +const CheckpointBody = v.object({ + progress: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(100)), + note: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(2000))), + links: v.optional(v.pipe(v.array(HttpsUrl), v.maxLength(10)), []), +}) +// 交付物只接受链接,平台不托管文件。 +const SubmitBody = v.object({ + summary: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)), + 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)), +}) +const ReviewBody = v.object({ + action: v.picklist(['accept', 'reject', 'select_winner']), + 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)), + reason: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(4000))), + submission_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))), +}) +const TransferBody = v.object({ to_agent_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) 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) { const params = v.parse(TaskParams, request.params) const result = await tasks.get(request.ctx, { agent: agentOf(request), task_id: params.id }) if (!result.ok) return refused(reply, result) - return ok({ ...taskView(result.task), acceptance: result.acceptance, entry_count: result.entry_count }) + return ok({ + ...taskView(result.task), + acceptance: result.acceptance, + entry_count: result.entry_count, + 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() })), + 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() })), + }) }) v1.patch('/tasks/:id', async (request, reply) => { @@ -165,13 +189,52 @@ export function registerV1Routes(app, services) { 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 })) }) - for (const type of /** @type {const} */ (['cancel', 'claim', 'enter'])) { + for (const type of /** @type {const} */ (['cancel', 'claim', 'enter', 'release', 'dispute'])) { v1.post(`/tasks/:id/${type}`, async (request, reply) => { const params = v.parse(TaskParams, request.params) return acted(reply, await tasks.act(request.ctx, { agent: agentOf(request), task_id: params.id, action: { type } })) }) } + v1.post('/tasks/:id/checkpoints', async (request, reply) => { + const params = v.parse(TaskParams, request.params) + const body = v.parse(CheckpointBody, request.body) + const result = await tasks.checkpoint(request.ctx, { agent: agentOf(request), task_id: params.id, progress: body.progress, note: body.note ?? null, links: body.links }) + if (!result.ok) return refused(reply, result) + return ok({ ...result.recorded, next_checkpoint_due: taskView(result.task).next_checkpoint_due }) + }) + + v1.post('/tasks/:id/submit', async (request, reply) => { + const params = v.parse(TaskParams, request.params) + const body = v.parse(SubmitBody, request.body) + const result = await tasks.submit(request.ctx, { agent: agentOf(request), task_id: params.id, summary: body.summary, artifacts: body.artifacts }) + if (!result.ok) return refused(reply, result) + return ok({ ...result.recorded, status: result.task.status, review_due: taskView(result.task).review_due }) + }) + + v1.post('/tasks/:id/review', async (request, reply) => { + const params = v.parse(TaskParams, request.params) + const body = v.parse(ReviewBody, request.body) + 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 }) + if (!result.ok) return refused(reply, result) + return ok({ ...result.recorded, ...taskView(result.task) }) + }) + + v1.post('/tasks/:id/transfer', async (request, reply) => { + const params = v.parse(TaskParams, request.params) + const body = v.parse(TransferBody, request.body) + const result = await tasks.requestTransfer(request.ctx, { agent: agentOf(request), task_id: params.id, to_agent_id: body.to_agent_id }) + if (!result.ok) return refused(reply, result) + return ok({ transfer_id: result.transfer_id, status: 'pending', expires_at: result.expires_at.toISOString() }) + }) + + for (const decision of /** @type {const} */ (['accept', 'decline'])) { + v1.post(`/tasks/:id/transfer/${decision}`, async (request, reply) => { + const params = v.parse(TaskParams, request.params) + return acted(reply, await tasks.answerTransfer(request.ctx, { agent: agentOf(request), task_id: params.id, decision })) + }) + } + v1.get('/tasks/:id/comments', async (request, reply) => { const params = v.parse(TaskParams, request.params) const result = await tasks.listComments(request.ctx, { agent: agentOf(request), task_id: params.id }) diff --git a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs index 3a982c8..57cfecc 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs @@ -117,20 +117,21 @@ function createWorld(name, db) { return owner } - async function createGuild(tag, leadUserId) { - 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` 可覆盖 require_lead_approval 与四个时间规则(分钟级 / 秒级的公会用于时间类用例)。 */ + async function createGuild(tag, leadUserId, settings = {}) { + 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 } }) check(`建公会 ${tag}`, guild.status === 200, guild) guildIds.push(guild.json.guild.id) return guild.json.guild.id } /** 注册一个 Agent 并由主人认领进公会;返回 { id, key }。 */ - async function createAgent(tag, owner, guildId) { + async function createAgent(tag, owner, guildId, role = 'member') { const registered = await call('POST', '/v1/agents/register', { body: { name: `${name}-${tag}-${run}`, skills: ['writing'] } }) check(`Agent ${tag}:注册`, registered.status === 200, registered) const data = registered.json.data agentIds.push(data.agent_id) - 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 } }) + 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 } }) check(`Agent ${tag}:认领`, claimed.status === 200 && claimed.json.agent.status === 'active', claimed) return { id: data.agent_id, key: data.api_key } } @@ -154,7 +155,8 @@ function createWorld(name, db) { const taskIds = guildIds.length === 0 ? [] : (await db('vk_tasks').whereIn('guild_id', guildIds).select('id')).map((row) => row.id) const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) if (taskIds.length > 0) { - 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() + await db('vk_review_checks').whereIn('review_id', db('vk_reviews').whereIn('task_id', taskIds).select('id')).del() + 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() await db('vk_jobs').whereIn('ref_id', taskIds).del() await db('vk_tasks').whereIn('id', taskIds).del() } @@ -181,5 +183,26 @@ function createWorld(name, db) { } } - return { db, results, check, call, createOwner, createGuild, createAgent, grantBudget, publish, cleanup } + /** 轮询任务详情直到满足条件(等真实 worker 执行),最多 `seconds` 秒;返回最后一次读到的任务。 */ + async function waitForTask(agent, taskId, predicate, seconds = 40) { + let task = null + for (let i = 0; i < seconds; i += 1) { + task = (await call('GET', `/v1/tasks/${taskId}`, { token: agent.key })).json.data + if (predicate(task)) return task + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + return task + } + + const wallet = async (agent) => (await call('GET', '/v1/wallet', { token: agent.key })).json.data + const SUBMISSION = { summary: 'Translated; see the doc.', artifacts: [{ type: 'link', url: 'https://example.com/doc/1', note: 'translation' }] } + const submit = (agent, taskId) => call('POST', `/v1/tasks/${taskId}/submit`, { token: agent.key, body: SUBMISSION }) + const claim = (agent, taskId) => call('POST', `/v1/tasks/${taskId}/claim`, { token: agent.key }) + /** 按清单逐条验收;`failing` 是不通过的清单项 id。 */ + const review = (agent, taskId, action, ids, failing = [], extra = {}) => call('POST', `/v1/tasks/${taskId}/review`, { + token: agent.key, + body: { action, checks: ids.map((id) => ({ id, pass: !failing.includes(id), reason: failing.includes(id) ? 'Not met yet.' : 'Looks good.' })), ...extra }, + }) + + return { db, results, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, cleanup } } diff --git a/apps/voko-server/tools/scenarios/voko-alpha/f1-publish.mjs b/apps/voko-server/tools/scenarios/voko-alpha/f1-publish.mjs index 27df057..fd06d0c 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/f1-publish.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/f1-publish.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node /** - * F1 发布的真实链路脚本(Planning 用例 F1-01、02、03、05、06、07、08;F1-04 发布前审核在 W3)。 + * F1 发布的真实链路脚本(Planning 用例 F1-01 – F1-08)。 */ import { randomUUID } from 'node:crypto' import { runScenario } from './_lib.mjs' -await runScenario('f1', async ({ db, check, call, createOwner, createGuild, createAgent, publish }) => { +await runScenario('f1', async ({ db, check, call, createOwner, createGuild, createAgent, publish, wallet: walletOf }) => { const alice = await createOwner('alice') const bob = await createOwner('bob') const guild = await createGuild('main', alice.user_id) @@ -81,4 +81,27 @@ await runScenario('f1', async ({ db, check, call, createOwner, createGuild, crea const entered = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: member.key }) const cancelContest = await call('POST', `/v1/tasks/${contest.id}/cancel`, { token: publisher.key }) check('F1-07 有人报名后撤回 → 409', entered.status === 200 && entered.json.data.status === 'in_contest' && cancelContest.status === 409, { entered, cancelContest }) + + // F1-04 公会开启发布前审核:公司 Agent 发布 → 待审核且成员不可见;负责人通过 → open;驳回 → cancelled 并退款。 + const strict = await createGuild('strict', alice.user_id, { require_lead_approval: true }) + const notLead = await call('POST', '/v1/agents/register', { body: { name: 'f1-not-lead', skills: [] } }) + 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' } }) + check('非负责人认领公司 Agent → 403', notLeadClaim.status === 403, notLeadClaim) + const company = await createAgent('company', alice, strict, 'company') + const staff = await createAgent('staff', bob, strict) + const pending = (await publish(company, { reward: 50 })).json.data + const staffList = await call('GET', '/v1/tasks', { token: staff.key }) + const staffGet = await call('GET', `/v1/tasks/${pending.id}`, { token: staff.key }) + 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 }) + const queue = await call('GET', '/api/voko/approvals', { token: alice.token }) + const bobDecides = await call('POST', `/api/voko/approvals/${pending.id}`, { token: bob.token, body: { decision: 'approve' } }) + check('负责人待审核列表里有它;非负责人审核 → 403', queue.json.tasks.some((t) => t.id === pending.id) && bobDecides.status === 403, { queue, bobDecides }) + const approved = await call('POST', `/api/voko/approvals/${pending.id}`, { token: alice.token, body: { decision: 'approve' } }) + const staffSees = await call('GET', `/v1/tasks/${pending.id}`, { token: staff.key }) + check('F1-04 负责人通过 → open,成员可见', approved.status === 200 && approved.json.task.status === 'open' && staffSees.status === 200, { approved, staffSees }) + const rejectedTask = (await publish(company, { reward: 30 })).json.data + const rejected = await call('POST', `/api/voko/approvals/${rejectedTask.id}`, { token: alice.token, body: { decision: 'reject' } }) + 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) }) + const memberPublish = (await publish(staff, { reward: 10 })).json.data + check('员工 Agent 在开启审核的公会发布 → 直接 open', memberPublish.status === 'open', memberPublish) }) diff --git a/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs b/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs index c5e0a8a..ad91c98 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node /** - * F2 接单的真实链路脚本(Planning 用例 F2-01、F2-02 的报名部分;检查点、接力、转让、提交在 W3)。 + * F2 接单与执行的真实链路脚本(Planning 用例 F2-01、02、03、05、06、07;F2-04 求助在 W4)。 */ import { runScenario } from './_lib.mjs' -await runScenario('f2', async ({ check, call, createOwner, createGuild, createAgent, grantBudget, publish }) => { +await runScenario('f2', async ({ db, check, call, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim }) => { const alice = await createOwner('alice') const bob = await createOwner('bob') const guild = await createGuild('main', alice.user_id) @@ -47,4 +47,58 @@ await runScenario('f2', async ({ check, call, createOwner, createGuild, createAg const again = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key }) const ownContest = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: sibling.key }) check('重复报名、同主人报名 → 409', again.status === 409 && ownContest.status === 409, { again, ownContest }) + const sealedSubmit = await submit(taker, contest.id) + const twice = await submit(taker, contest.id) + const outsiderSubmit = await submit(sibling, contest.id) + const asPublisher = await call('GET', `/v1/tasks/${contest.id}`, { token: publisher.key }) + const asRival = await call('GET', `/v1/tasks/${contest.id}`, { token: rival.key }) + const asTaker = await call('GET', `/v1/tasks/${contest.id}`, { token: taker.key }) + 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 }) + check('擂台赛重复提交 → 409,未报名提交 → 403', twice.status === 409 && outsiderSubmit.status === 403, { twice, outsiderSubmit }) + + // F2-03 检查点刷新下次到期时间。 + const working = tasks[0] + const before = (await call('GET', '/v1/home', { token: taker.key })).json.data.my_active_tasks.find((t) => t.id === working.id).next_checkpoint_due + await new Promise((resolve) => setTimeout(resolve, 1100)) + const checkpoint = await call('POST', `/v1/tasks/${working.id}/checkpoints`, { token: taker.key, body: { progress: 40, note: 'Draft done', links: ['https://example.com/draft'] } }) + const after = (await call('GET', '/v1/home', { token: taker.key })).json.data.my_active_tasks.find((t) => t.id === working.id).next_checkpoint_due + check('F2-03 检查点 → 刷新下次到期时间,/home 返回新时间', checkpoint.status === 200 && checkpoint.json.data.next_checkpoint_due === after && new Date(after) > new Date(before), { checkpoint, before, after }) + const notHolder = await call('POST', `/v1/tasks/${working.id}/checkpoints`, { token: rival.key, body: { progress: 10 } }) + check('非持有人上报检查点 → 403', notHolder.status === 403, notHolder) + + // F2-05 放弃 → 回 open、hold 记 released;接力者能读到全部历史检查点。 + const released = await call('POST', `/v1/tasks/${working.id}/release`, { token: taker.key }) + const hold = await db('vk_task_holds').where({ task_id: working.id }).first() + 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 }) + const relay = await claim(rival, working.id) + const relayView = await call('GET', `/v1/tasks/${working.id}`, { token: rival.key }) + check('F2-05 接力者能读到历史检查点', relay.status === 200 && relayView.json.data.checkpoints.length === 1 && relayView.json.data.checkpoints[0].note === 'Draft done', relayView) + + // F2-06 转让:拒绝 → 不变;接受 → 持有人变更。 + const moving = tasks[1] + const toSelf = await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: taker.id } }) + check('转给自己 → 422', toSelf.status === 422, toSelf) + const requested = await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: rival.id } }) + const duplicate = await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: rival.id } }) + check('发起转让 → pending;同一任务第二个待确认转让 → 409', requested.status === 200 && requested.json.data.status === 'pending' && duplicate.status === 409, { requested, duplicate }) + const rivalEvents = await call('GET', '/v1/events?since=0', { token: rival.key }) + check('被转让方收到 task.transfer_requested', rivalEvents.json.data.events.some((e) => e.type === 'task.transfer_requested' && e.task_id === moving.id), rivalEvents) + const declined = await call('POST', `/v1/tasks/${moving.id}/transfer/decline`, { token: rival.key }) + const afterDecline = await call('GET', `/v1/tasks/${moving.id}`, { token: taker.key }) + check('F2-06 对方拒绝 → 持有人不变', declined.status === 200 && afterDecline.json.data.holder_agent_id === taker.id, { declined, afterDecline }) + await call('POST', `/v1/tasks/${moving.id}/transfer`, { token: taker.key, body: { to_agent_id: rival.id } }) + const accepted = await call('POST', `/v1/tasks/${moving.id}/transfer/accept`, { token: rival.key }) + const holds = await db('vk_task_holds').where({ task_id: moving.id }).orderBy('started_at', 'asc') + 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 }) + const noPending = await call('POST', `/v1/tasks/${moving.id}/transfer/accept`, { token: taker.key }) + check('没有待确认转让时接受 → 409', noPending.status === 409, noPending) + + // F2-07 交付物只接受链接;提交后进入待验收。 + const delivering = tasks[2] + 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' }] } }) + const httpLink = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'link', url: 'http://example.com/a' }] } }) + check('F2-07 非链接或非 https 交付物 → 400', notLink.status === 400 && httpLink.status === 400, { notLink, httpLink }) + const submitted = await submit(taker, delivering.id) + const publisherHome = await call('GET', '/v1/home', { token: publisher.key }) + 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 }) }) diff --git a/apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs b/apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs new file mode 100644 index 0000000..cb6e7f4 --- /dev/null +++ b/apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * F3 验收与结算的真实链路脚本(Planning 用例 F3-01、02、03、04、05;F3-06 打赏在 W4)。 + * F3-05 需要 voko-worker 在跑(擂台赛到截止由 worker 关闭)。F3-04 的「AI 建议写入」依赖大模型装配,尚未覆盖。 + */ +import { runScenario } from './_lib.mjs' + +await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review }) => { + const alice = await createOwner('alice') + const bob = await createOwner('bob') + const carol = await createOwner('carol') + const guild = await createGuild('main', alice.user_id) + const publisher = await createAgent('publisher', alice, guild) + const taker = await createAgent('taker', bob, guild) + const helper = await createAgent('helper', carol, guild) + await grantBudget(publisher.id, 1000) + const items = [{ id: 'c1', text: 'Natural Japanese' }, { id: 'c2', text: 'Keeps all links' }] + const ids = ['c1', 'c2'] + const xpOf = async (agent) => (await call('GET', '/v1/agents/me', { token: agent.key })).json.data.xp + + // F3-01 验收格式。 + const first = (await publish(publisher, { reward: 60, acceptance: items })).json.data + await claim(taker, first.id) + const early = await review(publisher, first.id, 'accept', ids) + check('未提交就验收 → 409', early.status === 409, early) + await submit(taker, first.id) + const partial = await review(publisher, first.id, 'accept', ['c1']) + const contradiction = await review(publisher, first.id, 'accept', ids, ['c2']) + const allPassReject = await review(publisher, first.id, 'reject', ids) + 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 }) + const stranger = await review(taker, first.id, 'accept', ids) + check('非发布方验收 → 403', stranger.status === 403, stranger) + + // F3-02 通过 → 积分入接单方、托管 released、XP 增加、流水可查。 + const accepted = await review(publisher, first.id, 'accept', ids) + const escrow = await db('vk_escrows').where({ task_id: first.id }).first() + const payoutLedger = await db('billing_ledger').where({ ref_id: first.id, reason: 'vk_task_payout' }) + 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) }) + const again = await review(publisher, first.id, 'accept', ids) + check('已结算再验收 → 409,不重复发放', again.status === 409 && (await wallet(taker)).available === 160, again) + const takerEvents = await call('GET', '/v1/events?since=0', { token: taker.key }) + check('接单方收到 task.accepted', takerEvents.json.data.events.some((e) => e.type === 'task.accepted' && e.task_id === first.id), takerEvents) + + // F3-03 接力:helper 留检查点后放弃,taker 提交通过 → 平分,余数归 taker。 + const relayTask = (await publish(publisher, { reward: 101, acceptance: items })).json.data + await claim(helper, relayTask.id) + await call('POST', `/v1/tasks/${relayTask.id}/checkpoints`, { token: helper.key, body: { progress: 50, note: 'Half done' } }) + await call('POST', `/v1/tasks/${relayTask.id}/release`, { token: helper.key }) + await claim(taker, relayTask.id) + await submit(taker, relayTask.id) + const relayAccepted = await review(publisher, relayTask.id, 'accept', ids) + const payouts = await db('vk_escrow_payouts').where({ task_id: relayTask.id }).orderBy('amount', 'desc') + 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) }) + + // F3-04 驳回两次返工,第 3 次进仲裁;运营确认前不动账,确认后按结果结算。 + const hard = (await publish(publisher, { reward: 40, acceptance: items })).json.data + await claim(taker, hard.id) + for (const round of [1, 2]) { + await submit(taker, hard.id) + const rejected = await review(publisher, hard.id, 'reject', ids, ['c2'], { reason: 'Links are missing.' }) + check(`第 ${round} 次驳回 → rework,返工计数 ${round}`, rejected.status === 200 && rejected.json.data.status === 'rework' && rejected.json.data.rework_count === round, rejected) + } + await submit(taker, hard.id) + const third = await review(publisher, hard.id, 'reject', ids, ['c2'], { reason: 'Still missing.' }) + const dispute = await db('vk_disputes').where({ task_id: hard.id }).first() + 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 }) + const rounds = await db('vk_submissions').where({ task_id: hard.id }).orderBy('round', 'asc').select('round') + check('三轮提交各记一份', rounds.map((r) => r.round).join() === '1,2,3', rounds) + const adminToken = await admin() + const queue = await call('GET', '/api/voko/admin/disputes', { token: adminToken }) + check('运营仲裁队列里有这条争议', queue.status === 200 && queue.json.disputes.some((d) => d.task.id === hard.id), queue) + const notAdmin = await call('POST', `/api/voko/admin/disputes/${hard.id}/resolve`, { token: alice.token, body: { outcome: 'settled' } }) + check('非运营裁决 → 403', notAdmin.status === 403, notAdmin) + const resolved = await call('POST', `/api/voko/admin/disputes/${hard.id}/resolve`, { token: adminToken, body: { outcome: 'settled' } }) + 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) }) + + // 任一方发起争议 → 运营裁决退款。 + const disputed = (await publish(publisher, { reward: 30, acceptance: items })).json.data + await claim(taker, disputed.id) + await submit(taker, disputed.id) + const publisherBefore = (await wallet(publisher)).available + const opened = await call('POST', `/v1/tasks/${disputed.id}/dispute`, { token: taker.key }) + const refunded = await call('POST', `/api/voko/admin/disputes/${disputed.id}/resolve`, { token: adminToken, body: { outcome: 'refunded' } }) + check('接单方发起争议 → 运营裁决退款 → refunded,发布方拿回 30', opened.status === 200 && opened.json.data.status === 'disputed' && refunded.json.task.status === 'refunded' && (await wallet(publisher)).available === publisherBefore + 30, { opened, refunded }) + + // F3-05 擂台赛选赢家 → 赢家全额,其他人无流水。 + const contest = (await publish(publisher, { reward: 40, template: 'contest', acceptance: items, deadline: new Date(Date.now() + 5000).toISOString() })).json.data + await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key }) + await call('POST', `/v1/tasks/${contest.id}/enter`, { token: helper.key }) + const takerSubmission = await submit(taker, contest.id) + await submit(helper, contest.id) + const closed = await waitForTask(publisher, contest.id, (task) => task.status === 'reviewing') + check('擂台赛到截止由 worker 关闭 → reviewing,发布方看得到两份提交', closed.status === 'reviewing' && closed.submissions.length === 2, closed) + const noSubmission = await review(publisher, contest.id, 'select_winner', ids) + check('select_winner 未指明提交 → 422', noSubmission.status === 422, noSubmission) + const helperBefore = (await wallet(helper)).available + const won = await review(publisher, contest.id, 'select_winner', ids, [], { submission_id: takerSubmission.json.data.submission_id }) + const contestPayouts = await db('vk_escrow_payouts').where({ task_id: contest.id }) + 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 }) +}) diff --git a/apps/voko-server/tools/scenarios/voko-alpha/timers.mjs b/apps/voko-server/tools/scenarios/voko-alpha/timers.mjs index 9d87178..bb7d5c0 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/timers.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/timers.mjs @@ -1,22 +1,50 @@ #!/usr/bin/env node /** * 时间类规则的真实链路脚本,由真实 voko-worker 触发(运行前须已启动 `pnpm --filter @voko/server worker`)。 - * 目前覆盖 TM-05(open 过截止无人接 → cancelled 退款);TM-01–04、TM-06、F0-06 在 W3–W4 加入。 + * 用时间规则为秒级的公会(产品配置,不是测试开关)覆盖 TM-01、02、03、05。 + * TM-04(擂台赛过期由 AI 选赢家)待大模型装配;TM-06、F0-06 在 W4。 */ import { runScenario } from './_lib.mjs' -await runScenario('tm', async ({ check, call, createOwner, createGuild, createAgent, publish }) => { +await runScenario('tm', async ({ db, check, call, createOwner, createGuild, createAgent, publish, waitForTask, wallet, submit, claim }) => { const alice = await createOwner('alice') - const guild = await createGuild('main', alice.user_id) - const publisher = await createAgent('publisher', alice, guild) + const bob = await createOwner('bob') + // fast:检查点间隔 4 秒、验收窗口 6 秒;slow:默认规则,只用短截止。 + const fast = await createGuild('fast', alice.user_id, { checkpoint_interval_sec: 4, piece_review_window_sec: 6 }) + const slow = await createGuild('slow', alice.user_id) + const fastPublisher = await createAgent('fast-publisher', alice, fast) + const slowPublisher = await createAgent('slow-publisher', alice, slow) + const fastTaker = await createAgent('fast-taker', bob, fast) + const slowTaker = await createAgent('slow-taker', bob, slow) + const eventsOf = async (agent) => (await call('GET', '/v1/events?since=0', { token: agent.key })).json.data.events - const published = await publish(publisher, { reward: 40, deadline: new Date(Date.now() + 4000).toISOString() }) - check('发布 4 秒后截止的任务', published.status === 200 && published.json.data.status === 'open', published) - let task = published.json.data - for (let i = 0; i < 30 && task.status === 'open'; i += 1) { - await new Promise((resolve) => setTimeout(resolve, 1000)) - task = (await call('GET', `/v1/tasks/${task.id}`, { token: publisher.key })).json.data - } - const wallet = (await call('GET', '/v1/wallet', { token: publisher.key })).json.data - check('TM-05 open 过截止 → worker 取消并全额退款', task.status === 'cancelled' && wallet.available === 100 && wallet.held === 0, { task, wallet }) + // TM-05 open 过截止无人接 → cancelled 退款。 + const expiring = (await publish(slowPublisher, { reward: 40, deadline: new Date(Date.now() + 4000).toISOString() })).json.data + const expired = await waitForTask(slowPublisher, expiring.id, (task) => task.status !== 'open') + check('TM-05 open 过截止 → worker 取消并全额退款', expired.status === 'cancelled' && JSON.stringify(await wallet(slowPublisher)) === JSON.stringify({ available: 100, held: 0 }), { expired, wallet: await wallet(slowPublisher) }) + + // TM-01 截止未提交 → 释放回 open、发 task.released,截止按原工期顺延。 + const late = (await publish(slowPublisher, { reward: 20, deadline: new Date(Date.now() + 6000).toISOString() })).json.data + await claim(slowTaker, late.id) + const reopened = await waitForTask(slowPublisher, late.id, (task) => task.status === 'open') + const lateHold = await db('vk_task_holds').where({ task_id: late.id }).first() + 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 }) + check('TM-01 截止顺延,任务可以重新被接', new Date(reopened.deadline) > new Date(late.deadline) && (await claim(slowTaker, late.id)).status === 200, { old: late.deadline, next: reopened.deadline }) + + // TM-02 缺 1 个检查点只告警,连续缺 2 个自动释放。 + const idle = (await publish(fastPublisher, { reward: 20 })).json.data + const idleClaim = (await claim(fastTaker, idle.id)).json.data + const warned = await waitForTask(fastPublisher, idle.id, (task) => task.next_checkpoint_due !== idleClaim.next_checkpoint_due || task.status !== 'claimed') + 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) + const dropped = await waitForTask(fastPublisher, idle.id, (task) => task.status === 'open') + const idleHold = await db('vk_task_holds').where({ task_id: idle.id }).first() + check('TM-02 连续缺 2 个检查点 → 自动释放回 open', dropped.status === 'open' && idleHold.end_reason === 'timeout' && idleHold.has_valid_checkpoint === 0, { dropped, idleHold }) + + // TM-03 计件活验收窗口到期 → 自动结算,发 task.auto_accepted。 + const silent = (await publish(fastPublisher, { reward: 30 })).json.data + await claim(fastTaker, silent.id) + await submit(fastTaker, silent.id) + const auto = await waitForTask(fastPublisher, silent.id, (task) => task.status === 'settled') + const takerEvents = await eventsOf(fastTaker) + 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) }) }) diff --git a/apps/voko-web/src/api.js b/apps/voko-web/src/api.js index f3ad731..babe169 100644 --- a/apps/voko-web/src/api.js +++ b/apps/voko-web/src/api.js @@ -31,6 +31,11 @@ export async function api(method, path, body, idempotencyKey) { }, body: body === undefined ? undefined : JSON.stringify(body), }) + // 登录已过期:清掉 token 回登录页,登录后回到当前页(否则页面只会空白)。 + if (response.status === 401 && token !== null) { + setToken(null) + location.assign(`/login?next=${encodeURIComponent(location.pathname)}`) + } const json = await response.json() if (!response.ok) throw new Error(json.error.message) return json diff --git a/apps/voko-web/src/i18n/messages.js b/apps/voko-web/src/i18n/messages.js index ad81aec..8551934 100644 --- a/apps/voko-web/src/i18n/messages.js +++ b/apps/voko-web/src/i18n/messages.js @@ -42,6 +42,7 @@ export const messages = { guild: 'Guild', pickGuild: 'Choose a guild', submit: 'Claim agent', + asCompany: 'Claim as the guild\'s company agent (guild lead only)', done: 'Claimed. {name} is now active and received 100 starter points.', toAgents: 'Go to my agents', }, @@ -51,6 +52,7 @@ export const messages = { level: 'Level {level}', wallet: 'Available {available} · Held {held}', board: 'Guild board', + approvals: 'Tasks waiting for my approval', resetKey: 'Reset API key', newKey: 'New API key (shown once, give it to your agent):', suspend: 'Suspend', @@ -60,6 +62,13 @@ export const messages = { saved: 'Saved.', status: { pending_claim: 'Pending claim', active: 'Active', suspended: 'Suspended' }, }, + approvals: { + title: 'Tasks waiting for approval', + intro: 'Tasks published by your guild\'s company agent. Approving puts the task on the board; rejecting refunds the escrow.', + empty: 'Nothing is waiting for your approval.', + approve: 'Approve', + reject: 'Reject and refund', + }, board: { title: 'Guild board', back: 'Back to my agents', @@ -124,6 +133,7 @@ export const messages = { guild: '公会', pickGuild: '选择公会', submit: '认领', + asCompany: '认领为公会的公司 Agent(仅公会负责人)', done: '认领成功。{name} 已激活,并收到 100 启动积分。', toAgents: '去我的 Agent', }, @@ -133,6 +143,7 @@ export const messages = { level: '等级 {level}', wallet: '可用 {available} · 托管中 {held}', board: '公会看板', + approvals: '待我审核的任务', resetKey: '重置 API key', newKey: '新的 API key(只显示一次,请交给你的 Agent):', suspend: '停用', @@ -142,6 +153,13 @@ export const messages = { saved: '已保存。', status: { pending_claim: '待认领', active: '运行中', suspended: '已停用' }, }, + approvals: { + title: '待审核任务', + intro: '你负责的公会里由公司 Agent 发布的任务。通过后上板,驳回则退回托管积分。', + empty: '没有等待你审核的任务。', + approve: '通过', + reject: '驳回并退款', + }, board: { title: '公会看板', back: '返回我的 Agent', diff --git a/apps/voko-web/src/main.js b/apps/voko-web/src/main.js index 78350df..01be2f5 100644 --- a/apps/voko-web/src/main.js +++ b/apps/voko-web/src/main.js @@ -7,6 +7,7 @@ import App from './App.vue' import { messages } from './i18n/messages.js' import AdminPage from './pages/AdminPage.vue' import AgentsPage from './pages/AgentsPage.vue' +import ApprovalsPage from './pages/ApprovalsPage.vue' import BoardPage from './pages/BoardPage.vue' import ClaimPage from './pages/ClaimPage.vue' import HomePage from './pages/HomePage.vue' @@ -20,6 +21,7 @@ const router = createRouter({ { path: '/admin', component: AdminPage }, { path: '/claim/:token', component: ClaimPage }, { path: '/agents', component: AgentsPage }, + { path: '/approvals', component: ApprovalsPage }, { path: '/agents/:id/board', component: BoardPage }, ], }) diff --git a/apps/voko-web/src/pages/AgentsPage.vue b/apps/voko-web/src/pages/AgentsPage.vue index 9fea5f0..5b35aa1 100644 --- a/apps/voko-web/src/pages/AgentsPage.vue +++ b/apps/voko-web/src/pages/AgentsPage.vue @@ -66,6 +66,7 @@ onMounted(async () => { + + diff --git a/docs/runbooks/verify-alpha.md b/docs/runbooks/verify-alpha.md index e44db4a..4193a3a 100644 --- a/docs/runbooks/verify-alpha.md +++ b/docs/runbooks/verify-alpha.md @@ -4,7 +4,7 @@ ## 前提 -- 本地库已 `pnpm --filter @voko/server db:migrate` 到最新;voko-server 已启动;跑 `timers` 时 voko-worker 也要在跑(`pnpm --filter @voko/server worker`)。 +- 本地库已 `pnpm --filter @voko/server db:migrate` 到最新;voko-server 已启动;跑 `f3`、`timers` 时 voko-worker 也要在跑(`pnpm --filter @voko/server worker`)。 - `apps/voko-server/.env` 配置了真实 SMTP;`VOKO_TEST_EMAIL` 是能收信的邮箱;有一个管理员账号(本地 `pnpm dev:user admin@example.test admin123`)。 - 每个脚本会给测试邮箱的 plus 地址发若干封验证码与审核通过邮件。 @@ -13,10 +13,11 @@ ```bash export VOKO_TEST_EMAIL=you@gmail.com VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 pnpm --filter @voko/server verify:f0 # F0-01–05 入驻,见 verify-f0.md -pnpm --filter @voko/server verify:f1 # F1-01、02、03、05–08 发布、修改、撤回、评论 -pnpm --filter @voko/server verify:f2 # F2-01、F2-02 认领规则与擂台赛报名 +pnpm --filter @voko/server verify:f1 # F1-01–08 发布、修改、撤回、评论、发布前审核 +pnpm --filter @voko/server verify:f2 # F2-01、02、03、05、06、07 认领、检查点、接力、转让、提交 +pnpm --filter @voko/server verify:f3 # F3-01–05 验收、结算、接力平分、仲裁、擂台赛选赢家(需 worker) pnpm --filter @voko/server verify:concurrency # CC-01 并发认领、CC-02 发布幂等 -pnpm --filter @voko/server verify:timers # TM-05 过期退款(需 worker) +pnpm --filter @voko/server verify:timers # TM-01、02、03、05 超时释放、缺检查点、自动确认、过期退款(需 worker,约 1 分钟) pnpm --filter @voko/server verify:conservation # 只跑对账 ``` @@ -30,8 +31,10 @@ pnpm --filter @voko/server verify:conservation # 只跑对账 ## 网页端 -认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。 +认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board`、待审核 `/approvals` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。 ## 尚未覆盖 -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)。 +- 依赖大模型、待装配后接入:TM-04(擂台赛过期由 AI 选赢家)、F3-04 的「AI 建议写入」。在此之前 worker 对这两类 job 明确报错并重试到 failed,擂台赛可由发布方手动选赢家,争议由运营在 `/api/voko/admin/disputes` 手工裁决。 +- W4:F2-04 求助、F3-06 打赏、F0-06、TM-06、CC-03、WH、E2E。 +- CC-01 用 6 个 Agent 发 20 个并发请求(每个主人最多认领 3 个 Agent)。 diff --git a/packages/voko-access/src/agent-access.js b/packages/voko-access/src/agent-access.js index 62a9641..d8133ac 100644 --- a/packages/voko-access/src/agent-access.js +++ b/packages/voko-access/src/agent-access.js @@ -67,10 +67,10 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) { * 主人认领 Agent:核对验证码 → 计数 +1(≤3)→ Agent 激活并加入公会 → 发启动积分,全部在一个事务里。 * 同一主人重放已成功的认领返回同一结果,不重复计数与发放。 * @param {object} ctx - * @param {{ claim_token: string, user_id: string, verification_code: string, guild_id: string }} input + * @param {{ claim_token: string, user_id: string, verification_code: string, guild_id: string, role: 'company'|'member' }} input 公司 Agent 只能由公会负责人认领,每个公会一个 * @returns {Promise<{ ok: true, agent: import('@voko/exchange').Agent } | { ok: false, code: 'NOT_FOUND'|'FORBIDDEN'|'CONFLICT'|'CODE_INVALID', message: string }>} */ - async function claim(ctx, { claim_token, user_id, verification_code, guild_id }) { + async function claim(ctx, { claim_token, user_id, verification_code, guild_id, role }) { const now = new Date() return db.transaction(async (trx) => { 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 }) { throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`) } if (claimRow.verification_code !== verification_code) return refuse('CODE_INVALID', 'Verification code does not match') - if ((await trx('vk_guilds').where({ id: guild_id }).first('id')) === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`) + const guild = await trx('vk_guilds').where({ id: guild_id }).forUpdate().first('lead_user_id', 'company_agent_id') + if (guild === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`) + switch (role) { + case 'company': + if (guild.lead_user_id !== user_id) return refuse('FORBIDDEN', 'Only the guild lead can claim the company agent') + if (guild.company_agent_id !== null) return refuse('CONFLICT', 'This guild already has a company agent') + await trx('vk_guilds').where({ id: guild_id }).update({ company_agent_id: claimRow.agent_id, updated_at: now }) + break + case 'member': + break + default: + throw new Error(`Unhandled role: ${role}, id: ${claimRow.agent_id}`) + } if (request.claimed_agent_count >= MAX_AGENTS_PER_OWNER) return refuse('CONFLICT', `You can claim at most ${MAX_AGENTS_PER_OWNER} agents`) await trx('vk_access_requests').where({ id: request.id }).increment('claimed_agent_count', 1) await trx('vk_agent_claims').where({ id: claimRow.id }).update({ claimed_by_user_id: user_id, claimed_at: now }) - await agents.activate(ctx, trx, { agent_id: claimRow.agent_id, owner_user_id: user_id, guild_id, claimed_at: now }) + await agents.activate(ctx, trx, { agent_id: claimRow.agent_id, owner_user_id: user_id, guild_id, role, claimed_at: now }) await trx('vk_guild_members').insert({ id: `gm_${randomUUID()}`, guild_id, user_id, role: 'member', created_at: now }).onConflict(['guild_id', 'user_id']).ignore() const agent = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) await grantStarter(ctx, trx, { agent_id: agent.id, wallet_user_id: agent.wallet_user_id }) diff --git a/packages/voko-clearing/src/escrow.js b/packages/voko-clearing/src/escrow.js index 10e1b47..b940e2b 100644 --- a/packages/voko-clearing/src/escrow.js +++ b/packages/voko-clearing/src/escrow.js @@ -5,9 +5,10 @@ const ESCROWS = 'vk_escrows' const PAYOUTS = 'vk_escrow_payouts' const SOURCE = 'voko' const REF_TYPE = 'vk_task' +const PAYOUT_REASON = Object.freeze({ winner: 'vk_task_payout', relay_share: 'vk_relay_share' }) /** - * 托管:发布时从发布方钱包扣款并记 held,退回时原路发还。 + * 托管:发布时从发布方钱包扣款并记 held,结算时发给接单方,退回时原路发还。 * 所有方法都在调用方的事务(`trx`)里动账,业务表与账本一起提交或回滚;幂等键由任务唯一决定。 * 余额不足时账本抛 `BillingError(INSUFFICIENT_BALANCE)`,整个事务回滚。 * @@ -74,6 +75,35 @@ export function createEscrow({ db }) { } /** + * 结算:按给定的发放明细把托管发给接单方(含接力分成),escrow → released。 + * 各笔之和必须等于托管金额,否则 throw(宁可 500,不写脏账)。 + * @param {object} _ctx + * @param {import('knex').Knex} trx + * @param {{ task_id: string, payouts: Array<{ wallet_user_id: string, amount: number, kind: 'winner'|'relay_share' }> }} input + */ + async function settle(_ctx, trx, { task_id, payouts }) { + const escrow = await heldEscrow(trx, task_id) + const total = payouts.reduce((sum, payout) => sum + payout.amount, 0) + if (total !== escrow.amount) throw new Error(`vk_escrows settle payouts ${total} do not equal escrow amount ${escrow.amount}, id: ${escrow.id}`) + const now = new Date() + // 固定按钱包 id 排序动账,避免两笔结算互相等待对方的钱包行。 + for (const payout of [...payouts].sort((a, b) => (a.wallet_user_id < b.wallet_user_id ? -1 : 1))) { + const granted = await ledgerIn(trx).grant({ + user_id: payout.wallet_user_id, + amount: payout.amount, + source: SOURCE, + credit_kind: 'earned', + reason: PAYOUT_REASON[payout.kind], + ref_type: REF_TYPE, + ref_id: task_id, + idempotency_key: `vk:payout:${task_id}:${payout.wallet_user_id}:${payout.kind}`, + }) + 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 }) + } + await trx(ESCROWS).where({ id: escrow.id }).update({ status: 'released', closed_at: now }) + } + + /** * 钱包视图:可用 = 账本余额;冻结 = 该钱包名下 held 托管之和。 * @param {object} _ctx * @param {{ wallet_user_id: string }} input @@ -100,7 +130,7 @@ export function createEscrow({ db }) { return -Number(row.spent) } - return { hold, topupExists, topup, refund, walletOf, spentToday } + return { hold, topupExists, topup, refund, settle, walletOf, spentToday } } /** diff --git a/packages/voko-clearing/src/index.js b/packages/voko-clearing/src/index.js index 2780ae3..2a33729 100644 --- a/packages/voko-clearing/src/index.js +++ b/packages/voko-clearing/src/index.js @@ -1,7 +1,7 @@ /** * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 * - * 当前有托管的冻结、补扣与退回;结算(含接力分配)、打赏与对账在 W4 加入。 + * 当前有托管的冻结、补扣、结算与退回;打赏在 W4 加入。 * * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 */ diff --git a/packages/voko-exchange/src/agents.js b/packages/voko-exchange/src/agents.js index e6f2c7a..9ddf520 100644 --- a/packages/voko-exchange/src/agents.js +++ b/packages/voko-exchange/src/agents.js @@ -110,10 +110,10 @@ export function createAgents() { * 认领:pending_claim → active。行不在 pending_claim 即 throw(调用方已锁认领凭证,不应发生)。 * @param {object} _ctx * @param {import('knex').Knex} runner - * @param {{ agent_id: string, owner_user_id: string, guild_id: string, claimed_at: Date }} input + * @param {{ agent_id: string, owner_user_id: string, guild_id: string, role: 'company'|'member', claimed_at: Date }} input */ - async function activate(_ctx, runner, { agent_id, owner_user_id, guild_id, claimed_at }) { - 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 }) + async function activate(_ctx, runner, { agent_id, owner_user_id, guild_id, role, claimed_at }) { + 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 }) if (updated !== 1) throw new Error(`vk_agents activate expected pending_claim row, id: ${agent_id}`) } diff --git a/packages/voko-exchange/src/guilds.js b/packages/voko-exchange/src/guilds.js index 8cf702b..93c2152 100644 --- a/packages/voko-exchange/src/guilds.js +++ b/packages/voko-exchange/src/guilds.js @@ -15,11 +15,11 @@ export function createGuilds({ db }) { /** * 建公会并把负责人登记为成员。slug 已被占用返回 CONFLICT(由 UNIQUE 约束裁决)。 * @param {object} _ctx - * @param {{ name: string, slug: string, lead_user_id: string, require_lead_approval: boolean }} input + * @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) * @returns {Promise<{ ok: true, guild: Guild } | { ok: false, code: 'CONFLICT' }>} */ - async function create(_ctx, { name, slug, lead_user_id, require_lead_approval }) { - const guild = { id: `g_${randomUUID()}`, name, slug, lead_user_id, require_lead_approval, created_at: new Date() } + async function create(_ctx, { name, slug, lead_user_id, require_lead_approval, ...windows }) { + const guild = { id: `g_${randomUUID()}`, name, slug, lead_user_id, require_lead_approval, ...windows, created_at: new Date() } return db.transaction(async (trx) => { // slug 冲突由 UNIQUE 裁决:被忽略的插入读不回本次 id。 await trx(TABLE).insert(guild).onConflict('slug').ignore() diff --git a/packages/voko-exchange/src/jobs.js b/packages/voko-exchange/src/jobs.js index 08fef61..9200783 100644 --- a/packages/voko-exchange/src/jobs.js +++ b/packages/voko-exchange/src/jobs.js @@ -17,6 +17,7 @@ const WARN_KINDS = Object.freeze(['checkpoint_warn', 'review_warn']) * tasks: { * actAsWorker(ctx: object, input: { task_id: string, action: any }): Promise<{ ok: boolean }>, * warn(ctx: object, input: { task_id: string, kind: any }): Promise, + * expireTransfers(ctx: object, input: { task_id: string }): Promise, * }, * logger: { error(message: string, fields: object): void }, * }} deps @@ -68,13 +69,19 @@ export function createJobs({ db, tasks, logger }) { * @param {{ id: string, kind: string, ref_id: string|null }} job */ async function execute(ctx, job) { - if (TASK_ACTION_KINDS.includes(job.kind) || WARN_KINDS.includes(job.kind)) { - if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`) - if (WARN_KINDS.includes(job.kind)) await tasks.warn(ctx, { task_id: job.ref_id, kind: job.kind }) - else await tasks.actAsWorker(ctx, { task_id: job.ref_id, action: { type: job.kind } }) - return + if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`) + const task_id = job.ref_id + if (TASK_ACTION_KINDS.includes(job.kind)) return void await tasks.actAsWorker(ctx, { task_id, action: { type: job.kind } }) + if (WARN_KINDS.includes(job.kind)) return tasks.warn(ctx, { task_id, kind: job.kind }) + if (job.kind === 'transfer_expire') return tasks.expireTransfers(ctx, { task_id }) + if (job.kind === 'review_auto_accept') { + const row = await db('vk_tasks').where({ id: task_id }).first('template', 'status') + if (row === undefined) throw new Error(`vk_tasks row missing for review_auto_accept, id: ${task_id}`) + // 擂台赛到期要由大模型按清单选赢家;大模型尚未装配,这里明确失败,不替它做决定(发布方仍可手动选赢家)。 + 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}`) + return void await tasks.actAsWorker(ctx, { task_id, action: { type: 'review_auto_accept', winner_agent_id: null } }) } - // 其余类型(自动确认、仲裁、Webhook、日报、对账)在 W3–W4 接入。 + // 其余类型(仲裁意见、Webhook、日报、对账)在大模型装配后与 W4 接入。 throw new Error(`Unhandled job kind: ${job.kind}, id: ${job.id}`) } diff --git a/packages/voko-exchange/src/review.js b/packages/voko-exchange/src/review.js new file mode 100644 index 0000000..fb251ec --- /dev/null +++ b/packages/voko-exchange/src/review.js @@ -0,0 +1,25 @@ +/** + * 验收格式校验(平台只校验格式,判断由发布方做):checks 必须恰好覆盖全部清单项; + * 任一条不通过时动作必须是 reject,全部通过时必须是 accept / select_winner。 + * + * @param {{ action: 'accept'|'reject'|'select_winner', checks: Array<{ id: string, pass: boolean, reason: string }>, item_keys: string[] }} input + * @returns {string|null} 违反规则时返回给调用方的提示;合规返回 null + */ +export function reviewViolation({ action, checks, item_keys }) { + const seen = new Set(checks.map((check) => check.id)) + if (seen.size !== checks.length) return 'checks contains the same acceptance item more than once.' + const missing = item_keys.filter((key) => !seen.has(key)) + if (missing.length > 0) return `checks must cover every acceptance item; missing: ${missing.join(', ')}.` + const unknown = checks.filter((check) => !item_keys.includes(check.id)).map((check) => check.id) + if (unknown.length > 0) return `checks contains unknown acceptance items: ${unknown.join(', ')}.` + const allPass = checks.every((check) => check.pass) + switch (action) { + case 'accept': + case 'select_winner': + return allPass ? null : `${action} requires every acceptance item to pass; reject instead.` + case 'reject': + return allPass ? 'reject requires at least one failed acceptance item; accept instead.' : null + default: + throw new Error(`Unhandled review action: ${action}`) + } +} diff --git a/packages/voko-exchange/src/split-relay.js b/packages/voko-exchange/src/split-relay.js new file mode 100644 index 0000000..ff79f05 --- /dev/null +++ b/packages/voko-exchange/src/split-relay.js @@ -0,0 +1,37 @@ +/** + * 接力分配(2026-10-09 定:平分)。参与分配的人 = 最终提交者 + 所有留下过有效检查点后离开的前任 + * (has_valid_checkpoint 且 end_reason ∈ timeout / released / transferred);同一 Agent 多次持有只算一份。 + * 赏金按人数平分,除不尽的余数归最终提交者。各笔之和必须等于 reward。 + * + * @param {number} reward 正整数 + * @param {Array<{ agent_id: string, end_reason: 'submitted'|'released'|'timeout'|'transferred'|null, has_valid_checkpoint: boolean }>} holds 该任务的全部持有记录 + * @param {string} final_agent_id 最终提交者 + * @returns {Array<{ agent_id: string, amount: number, kind: 'winner'|'relay_share' }>} 最终提交者排第一 + */ +export function splitRelayPayout(reward, holds, final_agent_id) { + if (!Number.isSafeInteger(reward) || reward < 1) throw new Error(`splitRelayPayout: reward invalid: ${reward}`) + const relays = new Set() + for (const hold of holds) { + switch (hold.end_reason) { + case 'released': + case 'timeout': + case 'transferred': + if (hold.has_valid_checkpoint && hold.agent_id !== final_agent_id) relays.add(hold.agent_id) + break + case 'submitted': + case null: + break + default: + throw new Error(`Unhandled end_reason: ${hold.end_reason}, agent: ${hold.agent_id}`) + } + } + const share = Math.floor(reward / (relays.size + 1)) + // 人数多于积分时前任分不到整数份:全部归最终提交者(发放明细金额必须 > 0)。 + const paidRelays = share === 0 ? [] : [...relays] + const payouts = [ + { agent_id: final_agent_id, amount: reward - share * paidRelays.length, kind: /** @type {const} */ ('winner') }, + ...paidRelays.map((agent_id) => ({ agent_id, amount: share, kind: /** @type {const} */ ('relay_share') })), + ] + if (payouts.reduce((sum, payout) => sum + payout.amount, 0) !== reward) throw new Error(`splitRelayPayout: payouts do not sum to reward ${reward}`) + return payouts +} diff --git a/packages/voko-exchange/src/tasks.js b/packages/voko-exchange/src/tasks.js index 1dd5785..3b4e799 100644 --- a/packages/voko-exchange/src/tasks.js +++ b/packages/voko-exchange/src/tasks.js @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto' import { maxRarityForLevel, rarityForReward } from './rarity.js' +import { reviewViolation } from './review.js' +import { splitRelayPayout } from './split-relay.js' import { taskFromRow } from './task.js' import { publishStatus, transition } from './transition.js' @@ -9,6 +11,11 @@ const ITEMS = 'vk_task_acceptance_items' const HOLDS = 'vk_task_holds' const ENTRIES = 'vk_task_entries' const COMMENTS = 'vk_comments' +const CHECKPOINTS = 'vk_checkpoints' +const SUBMISSIONS = 'vk_submissions' +const REVIEWS = 'vk_reviews' +const DISPUTES = 'vk_disputes' +const TRANSFERS = 'vk_transfers' const EVENTS = 'vk_events' const JOBS = 'vk_jobs' @@ -30,6 +37,7 @@ const JOBS = 'vk_jobs' * topupExists(ctx: object, trx: import('knex').Knex, input: { task_id: string, idempotency_key: string }): Promise, * topup(ctx: object, trx: import('knex').Knex, input: { task_id: string, amount: number, idempotency_key: string }): Promise, * refund(ctx: object, trx: import('knex').Knex, input: { task_id: string }): Promise, + * settle(ctx: object, trx: import('knex').Knex, input: { task_id: string, payouts: Array<{ wallet_user_id: string, amount: number, kind: 'winner'|'relay_share' }> }): Promise, * spentToday(ctx: object, trx: import('knex').Knex, input: { wallet_user_id: string, now: Date }): Promise, * }, * }} deps @@ -83,16 +91,25 @@ export function createTasks({ db, agents, escrow }) { /** * Agent 对任务做一个动作。`idempotency_key` 只用于 edit 的补扣托管。 + * `prepare` 在锁住任务后、跑状态机前执行(读其它表做校验,或据此给出动作);`record` 在状态与副作用写完后执行(写检查点、提交、验收等记录)。 + * @template R * @param {object} ctx - * @param {{ agent: Agent, task_id: string, action: import('./transition.js').Action, idempotency_key?: string }} input - * @returns {Promise<{ ok: true, task: Task } | Refusal>} + * @param {{ + * agent: Agent, + * task_id: string, + * action?: import('./transition.js').Action, + * idempotency_key?: string, + * prepare?: (trx: import('knex').Knex, task: Task, now: Date) => Promise, + * record?: (trx: import('knex').Knex, before: Task, after: Task, now: Date) => Promise, + * }} input + * @returns {Promise<{ ok: true, task: Task, recorded: R } | Refusal>} */ - async function act(ctx, { agent, task_id, action, idempotency_key }) { + async function act(ctx, { agent, task_id, action, idempotency_key, prepare, record }) { return db.transaction(async (trx) => { const task = await lockTask(trx, task_id) const hidden = task === null ? refuse('not_found', `Task not found: ${task_id}`) : hiddenFrom(agent, task) if (task === null || hidden !== null) return /** @type {Refusal} */ (hidden) - if (action.type === 'edit' && await escrow.topupExists(ctx, trx, { task_id, idempotency_key: /** @type {string} */ (idempotency_key) })) return { ok: true, task } + 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) } // 锁 Agent 行:同一 Agent 并发认领不同任务时,持有数的读取与新增串行。 await trx('vk_agents').where({ id: agent.id }).forUpdate().first('id') const [holds, entry] = await Promise.all([ @@ -108,7 +125,12 @@ export function createTasks({ db, agents, escrow }) { active_hold_count: Number(holds.n), is_entrant: entry !== undefined, } - return apply(ctx, trx, task, action, actor, idempotency_key) + const now = new Date() + const prepared = prepare === undefined ? null : await prepare(trx, task, now) + if (prepared !== null && 'ok' in prepared) return prepared + const chosen = prepared === null ? action : prepared.action + if (chosen === undefined) throw new Error(`act: no action for task ${task_id}`) + return apply(ctx, trx, task, chosen, actor, idempotency_key, now, record) }) } @@ -122,7 +144,7 @@ export function createTasks({ db, agents, escrow }) { return db.transaction(async (trx) => { const task = await lockTask(trx, task_id) if (task === null) return refuse('not_found', `Task not found: ${task_id}`) - return apply(ctx, trx, task, action, { kind: 'worker' }, undefined) + return apply(ctx, trx, task, action, { kind: 'worker' }, undefined, new Date(), undefined) }) } @@ -133,10 +155,11 @@ export function createTasks({ db, agents, escrow }) { * @param {import('./transition.js').Action} action * @param {import('./transition.js').Actor} actor * @param {string|undefined} idempotency_key - * @returns {Promise<{ ok: true, task: Task } | Refusal>} + * @param {Date} now + * @param {((trx: import('knex').Knex, before: Task, after: Task, now: Date) => Promise)|undefined} record + * @returns {Promise<{ ok: true, task: Task, recorded: any } | Refusal>} */ - async function apply(ctx, trx, task, action, actor, idempotency_key) { - const now = new Date() + async function apply(ctx, trx, task, action, actor, idempotency_key, now, record) { const guild = await guildRow(trx, task.guild_id) const publisher = await agents.getById(ctx, trx, { agent_id: task.publisher_agent_id }) 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 }) { await insertJob(trx, task.id, effect.kind, effect.run_at, now) break case 'escrow_settle': + await settle(ctx, trx, next, effect.winner_agent_id) + break case 'dispute_open': - // 结算(W4)与仲裁(W3)的执行尚未实现;现有接口到不了这里。 - throw new Error(`Effect not implemented yet: ${effect.type}, id: ${task.id}`) + 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 }) + break default: throw new Error(`Unhandled effect: ${/** @type {any} */ (effect).type}, id: ${task.id}`) } } - return { ok: true, task: { ...next, updated_at: now } } + const after = { ...next, updated_at: now } + return { ok: true, task: after, recorded: record === undefined ? undefined : await record(trx, task, after, now) } + } + + /** + * 结算:计件活按接力规则在最终提交者与留过有效检查点的前任之间平分,擂台赛赢家通吃;收款人按到手积分加 XP。 + * @param {object} ctx + * @param {import('knex').Knex} trx + * @param {Task} task + * @param {string} winner_agent_id + */ + async function settle(ctx, trx, task, winner_agent_id) { + /** @type {Array<{ agent_id: string, amount: number, kind: 'winner'|'relay_share' }>} */ + let shares + switch (task.template) { + case 'piecework': + 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) + break + case 'contest': + shares = [{ agent_id: winner_agent_id, amount: task.reward, kind: 'winner' }] + break + default: + throw new Error(`Unhandled template: ${task.template}, id: ${task.id}`) + } + const payouts = [] + for (const share of shares) { + const recipient = await agents.getById(ctx, trx, { agent_id: share.agent_id }) + payouts.push({ wallet_user_id: recipient.wallet_user_id, amount: share.amount, kind: share.kind }) + await trx('vk_agents').where({ id: recipient.id }).increment('xp', share.amount) + } + await escrow.settle(ctx, trx, { task_id: task.id, payouts }) } /** @@ -220,7 +275,7 @@ export function createTasks({ db, agents, escrow }) { /** * @param {object} _ctx * @param {{ agent: Agent, task_id: string }} input - * @returns {Promise<{ ok: true, task: Task, acceptance: AcceptanceItem[], entry_count: number } | Refusal>} + * @returns {Promise<{ ok: true, task: Task, acceptance: AcceptanceItem[], entry_count: number, checkpoints: any[], submissions: any[] } | Refusal>} */ async function get(_ctx, { agent, task_id }) { const row = await db(TASKS).where({ id: task_id }).first() @@ -228,8 +283,15 @@ export function createTasks({ db, agents, escrow }) { const task = taskFromRow(row) const hidden = hiddenFrom(agent, task) if (hidden !== null) return hidden - const entries = await db(ENTRIES).where({ task_id }).count({ n: '*' }).first() - return { ok: true, task, acceptance: await acceptanceOf(db, task_id), entry_count: Number(entries.n) } + const [entries, checkpoints, submissions] = await Promise.all([ + db(ENTRIES).where({ task_id }).count({ n: '*' }).first(), + db(CHECKPOINTS).where({ task_id }).orderBy('created_at', 'asc').select('id', 'agent_id', 'progress', 'note', 'links', 'created_at'), + db(SUBMISSIONS).where({ task_id }).orderBy('created_at', 'asc').select('id', 'agent_id', 'round', 'summary', 'artifacts', 'created_at'), + ]) + // 交付只给发布方和提交者本人看;擂台赛截止前对发布方也封存。 + const sealed = task.status === 'in_contest' + const visible = submissions.filter((submission) => submission.agent_id === agent.id || (task.publisher_agent_id === agent.id && !sealed)) + return { ok: true, task, acceptance: await acceptanceOf(db, task_id), entry_count: Number(entries.n), checkpoints, submissions: visible } } /** @@ -289,6 +351,219 @@ export function createTasks({ db, agents, escrow }) { } /** + * 上报检查点:刷新下次到期时间,并记为该次持有的有效检查点(接力分成的依据)。 + * @param {object} ctx + * @param {{ agent: Agent, task_id: string, progress: number, note: string|null, links: string[] }} input + */ + async function checkpoint(ctx, { agent, task_id, progress, note, links }) { + return act(ctx, { + agent, + task_id, + action: { type: 'checkpoint' }, + async record(trx, _before, _after, now) { + const hold = await trx(HOLDS).where({ task_id }).whereNull('ended_at').first('id') + const id = `cp_${randomUUID()}` + await trx(CHECKPOINTS).insert({ id, task_id, hold_id: hold.id, agent_id: agent.id, progress, note, links: JSON.stringify(links), created_at: now }) + return { checkpoint_id: id } + }, + }) + } + + /** + * 提交交付物。计件活每轮一份(第 n 次返工是第 n+1 轮);擂台赛每个参赛者一份,截止前封存。 + * @param {object} ctx + * @param {{ agent: Agent, task_id: string, summary: string, artifacts: Array<{ type: 'link', url: string, note?: string }> }} input + */ + async function submit(ctx, { agent, task_id, summary, artifacts }) { + return act(ctx, { + agent, + task_id, + action: { type: 'submit' }, + async prepare(trx, task) { + if (task.template !== 'contest') return null + const existing = await trx(SUBMISSIONS).where({ task_id, agent_id: agent.id }).first('id') + return existing === undefined ? null : refuse('state_conflict', 'You have already submitted to this contest.') + }, + async record(trx, before, _after, now) { + const id = `s_${randomUUID()}` + 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 }) + return { submission_id: id } + }, + }) + } + + /** + * 发布方按清单逐条验收。计件活验的是持有人最新一轮提交;擂台赛用 select_winner 并指明赢家的提交。 + * @param {object} ctx + * @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 + */ + async function review(ctx, { agent, task_id, action, checks, reason, submission_id }) { + /** @type {string} */ + let reviewed + return act(ctx, { + agent, + task_id, + async prepare(trx) { + const violation = reviewViolation({ action, checks, item_keys: (await acceptanceOf(trx, task_id)).map((item) => item.id) }) + if (violation !== null) return refuse('rule_violation', violation) + switch (action) { + case 'accept': + case 'reject': { + const latest = await trx(SUBMISSIONS).where({ task_id }).orderBy('created_at', 'desc').first('id') + if (latest === undefined) return refuse('state_conflict', 'There is no submission to review yet.') + reviewed = latest.id + return { action: { type: action } } + } + case 'select_winner': { + const winner = submission_id === null ? undefined : await trx(SUBMISSIONS).where({ id: submission_id, task_id }).first('id', 'agent_id') + if (winner === undefined) return refuse('rule_violation', 'select_winner requires the submission_id of a submission to this task.') + reviewed = winner.id + return { action: { type: 'select_winner', winner_agent_id: winner.agent_id } } + } + default: + throw new Error(`Unhandled review action: ${action}, id: ${task_id}`) + } + }, + async record(trx, _before, _after, now) { + const id = `rv_${randomUUID()}` + await trx(REVIEWS).insert({ id, task_id, submission_id: reviewed, reviewer_kind: 'publisher', action, reason, created_at: now }) + await trx('vk_review_checks').insert(checks.map((check) => ({ review_id: id, item_key: check.id, pass: check.pass, reason: check.reason }))) + return { review_id: id } + }, + }) + } + + /** + * 持有人把任务转给同公会的另一个 Agent,对方接受后才换人;同一任务同时只有一个待确认的转让。 + * @param {object} _ctx + * @param {{ agent: Agent, task_id: string, to_agent_id: string }} input + * @returns {Promise<{ ok: true, transfer_id: string, expires_at: Date } | Refusal>} + */ + async function requestTransfer(_ctx, { agent, task_id, to_agent_id }) { + return db.transaction(async (trx) => { + const task = await lockTask(trx, task_id) + const hidden = task === null ? refuse('not_found', `Task not found: ${task_id}`) : hiddenFrom(agent, task) + if (task === null || hidden !== null) return /** @type {Refusal} */ (hidden) + if (task.status !== 'claimed') return refuse('state_conflict', `Task is ${task.status}; only a claimed task can be transferred.`) + if (task.holder_agent_id !== agent.id) return refuse('forbidden', 'Only the current holder can transfer.') + const target = await trx('vk_agents').where({ id: to_agent_id, guild_id: task.guild_id, status: 'active' }).first('id') + if (target === undefined || to_agent_id === agent.id) return refuse('rule_violation', 'to_agent_id must be another active agent in this guild.') + if ((await trx(TRANSFERS).where({ task_id, status: 'pending' }).first('id')) !== undefined) return refuse('state_conflict', 'This task already has a pending transfer.') + const now = new Date() + const guild = await guildRow(trx, task.guild_id) + 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 } + await trx(TRANSFERS).insert(transfer) + await insertEvent(trx, task, 'task.transfer_requested', to_agent_id, now) + await insertJob(trx, task_id, 'transfer_expire', transfer.expires_at, now) + return { ok: true, transfer_id: transfer.id, expires_at: transfer.expires_at } + }) + } + + /** + * 被转让方接受或拒绝。接受时走状态机换持有人(自接自单、并发上限、稀有度同样适用)。 + * @param {object} ctx + * @param {{ agent: Agent, task_id: string, decision: 'accept'|'decline' }} input + * @returns {Promise<{ ok: true, task: Task } | Refusal>} + */ + async function answerTransfer(ctx, { agent, task_id, decision }) { + /** @param {import('knex').Knex} trx @param {Date} now */ + const pendingForMe = async (trx, now) => { + const transfer = await trx(TRANSFERS).where({ task_id, to_agent_id: agent.id, status: 'pending' }).first('id', 'expires_at') + return transfer === undefined || transfer.expires_at <= now ? null : transfer + } + switch (decision) { + case 'accept': + return act(ctx, { + agent, + task_id, + action: { type: 'transfer_accept' }, + prepare: async (trx, _task, now) => ((await pendingForMe(trx, now)) === null ? refuse('state_conflict', 'There is no pending transfer of this task to you.') : null), + record: async (trx, _before, _after, now) => { + await trx(TRANSFERS).where({ task_id, to_agent_id: agent.id, status: 'pending' }).update({ status: 'accepted', resolved_at: now }) + }, + }) + case 'decline': + return db.transaction(async (trx) => { + const task = await lockTask(trx, task_id) + const now = new Date() + if (task === null || (await pendingForMe(trx, now)) === null) return refuse('state_conflict', 'There is no pending transfer of this task to you.') + await trx(TRANSFERS).where({ task_id, to_agent_id: agent.id, status: 'pending' }).update({ status: 'declined', resolved_at: now }) + return { ok: true, task } + }) + default: + throw new Error(`Unhandled transfer decision: ${decision}, id: ${task_id}`) + } + } + + /** + * worker:到期仍待确认的转让记为 expired。 + * @param {object} _ctx + * @param {{ task_id: string }} input + */ + async function expireTransfers(_ctx, { task_id }) { + const now = new Date() + await db(TRANSFERS).where({ task_id, status: 'pending' }).where('expires_at', '<=', now).update({ status: 'expired', resolved_at: now }) + } + + /** + * 负责人审核公司 Agent 发布的任务:通过上板,驳回退款。 + * @param {object} ctx + * @param {{ user_id: string, task_id: string, decision: 'approve'|'reject' }} input + * @returns {Promise<{ ok: true, task: Task } | Refusal>} + */ + async function decideApproval(ctx, { user_id, task_id, decision }) { + return db.transaction(async (trx) => { + const task = await lockTask(trx, task_id) + if (task === null) return refuse('not_found', `Task not found: ${task_id}`) + const guild = await trx('vk_guilds').where({ id: task.guild_id }).first('lead_user_id') + if (guild.lead_user_id !== user_id) return refuse('forbidden', 'Only the guild lead can review this task.') + return apply(ctx, trx, task, { type: decision === 'approve' ? 'approve' : 'reject_approval' }, { kind: 'lead', user_id }, undefined, new Date(), undefined) + }) + } + + /** + * 负责人名下公会里等待审核的任务。 + * @param {object} _ctx + * @param {{ user_id: string }} input + * @returns {Promise} + */ + async function listPendingApprovals(_ctx, { user_id }) { + 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') + return rows.map(taskFromRow) + } + + /** + * 运营裁决争议:结算给接单方(计件活为持有人,擂台赛须指明赢家)或全额退回发布方。 + * @param {object} ctx + * @param {{ user_id: string, task_id: string, outcome: 'settled'|'refunded', winner_agent_id: string|null }} input + * @returns {Promise<{ ok: true, task: Task } | Refusal>} + */ + async function resolveDispute(ctx, { user_id, task_id, outcome, winner_agent_id }) { + return db.transaction(async (trx) => { + const task = await lockTask(trx, task_id) + if (task === null) return refuse('not_found', `Task not found: ${task_id}`) + const winner = outcome === 'settled' ? (task.holder_agent_id === null ? winner_agent_id : task.holder_agent_id) : null + if (outcome === 'settled' && winner === null) return refuse('rule_violation', 'winner_agent_id is required to settle a contest dispute.') + 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.') + const now = new Date() + return apply(ctx, trx, task, { type: 'resolve', outcome, winner_agent_id: winner }, { kind: 'operator', user_id }, undefined, now, async () => { + 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 }) + }) + }) + } + + /** + * 运营的仲裁队列:未关闭的争议及其任务。 + * @param {object} _ctx + * @returns {Promise>} + */ + async function listOpenDisputes(_ctx) { + const disputes = await db(DISPUTES).whereNot({ status: 'resolved' }).orderBy('created_at', 'asc') + const tasks = disputes.length === 0 ? [] : await db(TASKS).whereIn('id', disputes.map((dispute) => dispute.task_id)) + 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)) })) + } + + /** * 到期前提醒:任务仍在等待对应动作时给当事人发事件;状态已变则无事可做。 * @param {object} _ctx * @param {{ task_id: string, kind: 'checkpoint_warn'|'review_warn' }} input @@ -310,7 +585,7 @@ export function createTasks({ db, agents, escrow }) { } } - return { publish, act, actAsWorker, warn, list, get, addComment, listComments, homeFor } + return { publish, act, actAsWorker, checkpoint, submit, review, requestTransfer, answerTransfer, expireTransfers, decideApproval, listPendingApprovals, resolveDispute, listOpenDisputes, warn, list, get, addComment, listComments, homeFor } } /** @@ -340,7 +615,7 @@ async function lockTask(trx, task_id) { * @param {string} guild_id */ async function guildRow(runner, guild_id) { - 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') + 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') if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`) return guild } diff --git a/packages/voko-exchange/src/transition.js b/packages/voko-exchange/src/transition.js index 6412ff0..9433045 100644 --- a/packages/voko-exchange/src/transition.js +++ b/packages/voko-exchange/src/transition.js @@ -184,18 +184,18 @@ const TRANSITIONS = { if (actor.kind !== 'worker') return notAllowed('Only the platform can record a missed checkpoint.') const due = /** @type {Date} */ (task.next_checkpoint_due_at) if (now < due) return fail('state_conflict', 'Checkpoint is not overdue.') - if (task.missed_checkpoints >= 1) return ok(reopen(task), releaseEffects(task, 'timeout')) + if (task.missed_checkpoints >= 1) return released(task, 'timeout', now) // 第一次缺失只告警:从原到期时刻顺延一个间隔。 return ok({ ...task, next_checkpoint_due_at: checkpointDue(state, due), missed_checkpoints: 1 }, checkpointJobs(state, due)) }, - release({ task }, _action, actor) { + release({ task }, _action, actor, now) { if (!isHolder(task, actor)) return notAllowed('Only the current holder can release.') - return ok(reopen(task), releaseEffects(task, 'released')) + return released(task, 'released', now) }, hold_timeout({ task }, _action, actor, now) { if (actor.kind !== 'worker') return notAllowed('Only the platform can time out a hold.') if (now < task.deadline_at) return fail('state_conflict', 'Deadline has not passed.') - return ok(reopen(task), releaseEffects(task, 'timeout')) + return released(task, 'timeout', now) }, transfer_accept(state, _action, actor, now) { const refused = takerRefusal(state, actor) @@ -371,10 +371,25 @@ function hold(state, agentId, now) { /** * @param {Task} task + * @param {'released'|'timeout'} reason + * @param {Date} now + * @returns {TransitionResult} + */ +function released(task, reason, now) { + const reopened = reopen(task, now) + return ok(reopened, releaseEffects(task, reopened, reason)) +} + +/** + * 任务回到 open。若此时已过截止,按原先给的工期(created_at → deadline_at)从现在起重新计时, + * 否则任务既接不了也不会过期,会卡死。 + * @param {Task} task + * @param {Date} now * @returns {Task} */ -function reopen(task) { - return { ...task, status: 'open', holder_agent_id: null, next_checkpoint_due_at: null, missed_checkpoints: 0 } +function reopen(task, now) { + const deadline = now < task.deadline_at ? task.deadline_at : new Date(now.getTime() + (task.deadline_at.getTime() - task.created_at.getTime())) + return { ...task, status: 'open', deadline_at: deadline, holder_agent_id: null, next_checkpoint_due_at: null, missed_checkpoints: 0 } } /** @@ -402,12 +417,19 @@ function submitted(state, now, before) { } /** - * @param {Task} task + * @param {Task} task 释放前的任务 + * @param {Task} reopened 释放后的任务 * @param {'released'|'timeout'} reason * @returns {Effect[]} */ -function releaseEffects(task, reason) { - return [{ type: 'hold_end', reason }, event('task.released', /** @type {string} */ (task.holder_agent_id)), event('task.released', task.publisher_agent_id)] +function releaseEffects(task, reopened, reason) { + return [ + { type: 'hold_end', reason }, + event('task.released', /** @type {string} */ (task.holder_agent_id)), + event('task.released', task.publisher_agent_id), + // 截止被顺延时要按新截止重新排「过期无人接」。 + ...(reopened.deadline_at.getTime() === task.deadline_at.getTime() ? [] : openJobs(reopened)), + ] } /** diff --git a/packages/voko-exchange/test/review.test.js b/packages/voko-exchange/test/review.test.js new file mode 100644 index 0000000..5a2f53d --- /dev/null +++ b/packages/voko-exchange/test/review.test.js @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { reviewViolation } from '../src/review.js' + +const item_keys = ['c1', 'c2'] +const pass = (id) => ({ id, pass: true, reason: 'ok' }) +const failed = (id) => ({ id, pass: false, reason: 'not done' }) + +describe('验收格式校验', () => { + it('全部通过只能 accept / select_winner', () => { + expect(reviewViolation({ action: 'accept', checks: [pass('c1'), pass('c2')], item_keys })).toBeNull() + expect(reviewViolation({ action: 'select_winner', checks: [pass('c1'), pass('c2')], item_keys })).toBeNull() + expect(reviewViolation({ action: 'reject', checks: [pass('c1'), pass('c2')], item_keys })).toMatch(/at least one failed/) + }) + + it('任一条不通过只能 reject', () => { + expect(reviewViolation({ action: 'reject', checks: [pass('c1'), failed('c2')], item_keys })).toBeNull() + expect(reviewViolation({ action: 'accept', checks: [pass('c1'), failed('c2')], item_keys })).toMatch(/reject instead/) + expect(reviewViolation({ action: 'select_winner', checks: [failed('c1'), pass('c2')], item_keys })).toMatch(/reject instead/) + }) + + it('必须恰好覆盖全部清单项', () => { + expect(reviewViolation({ action: 'accept', checks: [pass('c1')], item_keys })).toMatch(/missing: c2/) + expect(reviewViolation({ action: 'accept', checks: [pass('c1'), pass('c2'), pass('c9')], item_keys })).toMatch(/unknown acceptance items: c9/) + expect(reviewViolation({ action: 'accept', checks: [pass('c1'), pass('c1'), pass('c2')], item_keys })).toMatch(/more than once/) + }) + + it('未知动作直接 throw', () => { + expect(() => reviewViolation({ action: 'approve', checks: [pass('c1'), pass('c2')], item_keys })).toThrow(/Unhandled review action: approve/) + }) +}) diff --git a/packages/voko-exchange/test/split-relay.test.js b/packages/voko-exchange/test/split-relay.test.js new file mode 100644 index 0000000..d497abe --- /dev/null +++ b/packages/voko-exchange/test/split-relay.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { splitRelayPayout } from '../src/split-relay.js' + +const hold = (agent_id, end_reason, has_valid_checkpoint) => ({ agent_id, end_reason, has_valid_checkpoint }) + +describe('SM-02 splitRelayPayout', () => { + it('1 人:全额归提交者', () => { + expect(splitRelayPayout(100, [hold('b', 'submitted', true)], 'b')).toEqual([{ agent_id: 'b', amount: 100, kind: 'winner' }]) + }) + + it('2 人平分', () => { + expect(splitRelayPayout(100, [hold('a', 'released', true), hold('b', 'submitted', false)], 'b')).toEqual([ + { agent_id: 'b', amount: 50, kind: 'winner' }, + { agent_id: 'a', amount: 50, kind: 'relay_share' }, + ]) + }) + + it('3 人除不尽:余数归最终提交者', () => { + const payouts = splitRelayPayout(100, [hold('a', 'timeout', true), hold('c', 'transferred', true), hold('b', 'submitted', true)], 'b') + expect(payouts).toEqual([ + { agent_id: 'b', amount: 34, kind: 'winner' }, + { agent_id: 'a', amount: 33, kind: 'relay_share' }, + { agent_id: 'c', amount: 33, kind: 'relay_share' }, + ]) + }) + + it('同一 Agent 多次持有只算一份;没留有效检查点的前任不参与', () => { + const holds = [hold('a', 'released', true), hold('a', 'timeout', true), hold('d', 'released', false), hold('b', 'released', true), hold('b', 'submitted', true)] + expect(splitRelayPayout(90, holds, 'b')).toEqual([ + { agent_id: 'b', amount: 45, kind: 'winner' }, + { agent_id: 'a', amount: 45, kind: 'relay_share' }, + ]) + }) + + it('各笔之和恒等于 reward(含赏金小于人数)', () => { + const holds = [hold('a', 'released', true), hold('c', 'released', true), hold('b', 'submitted', true)] + for (const reward of [1, 2, 3, 4, 7, 100, 101]) { + const payouts = splitRelayPayout(reward, holds, 'b') + expect(payouts.reduce((sum, p) => sum + p.amount, 0)).toBe(reward) + expect(payouts.every((p) => p.amount > 0)).toBe(true) + } + expect(splitRelayPayout(2, holds, 'b')).toEqual([{ agent_id: 'b', amount: 2, kind: 'winner' }]) + }) + + it('全部结束原因都被处理;未知原因直接 throw', () => { + for (const reason of ['submitted', 'released', 'timeout', 'transferred', null]) expect(() => splitRelayPayout(10, [hold('a', reason, true)], 'b')).not.toThrow() + expect(() => splitRelayPayout(10, [hold('a', 'vanished', true)], 'b')).toThrow(/Unhandled end_reason: vanished/) + }) +}) diff --git a/packages/voko-exchange/test/transition.test.js b/packages/voko-exchange/test/transition.test.js index a6ab07c..34233e2 100644 --- a/packages/voko-exchange/test/transition.test.js +++ b/packages/voko-exchange/test/transition.test.js @@ -40,7 +40,7 @@ function stateFor(status, template = TEMPLATE_OF[status] ?? 'piecework', overrid rework_count: status === 'rework' ? 1 : 0, review_due_at: ['submitted', 'reviewing'].includes(status) ? PAST : null, settled_at: status === 'settled' ? PAST : null, - created_at: PAST, + created_at: new Date('2026-10-12T00:00:00.000Z'), updated_at: null, ...overrides, }) @@ -167,11 +167,19 @@ describe('SM-01 副作用与平台规则', () => { expect(first.task).toMatchObject({ status: 'claimed', missed_checkpoints: 1, next_checkpoint_due_at: new Date('2026-10-12T12:00:00.000Z') }) const second = run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint_miss') expect(second.task).toMatchObject({ status: 'open', holder_agent_id: null, missed_checkpoints: 0 }) - expect(effectTypes(second)).toEqual(['hold_end', 'task.released', 'task.released']) + // 夹具的截止已过:按原工期(6 小时)从现在重新计时,并重排过期 job。 + expect(effectTypes(second)).toEqual(['hold_end', 'task.released', 'task.released', 'job:open_expire']) + expect(second.task.deadline_at).toEqual(new Date('2026-10-12T18:00:00.000Z')) expect(second.effects[0].reason).toBe('timeout') expect(run(stateFor('claimed', 'piecework', { missed_checkpoints: 1 }), 'checkpoint').task.missed_checkpoints).toBe(0) }) + it('截止前主动释放:截止不变、不重排过期 job', () => { + const result = run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'release') + expect(result.task).toMatchObject({ status: 'open', deadline_at: FUTURE }) + expect(effectTypes(result)).toEqual(['hold_end', 'task.released', 'task.released']) + }) + it('未到期的 worker 动作是过时 job,返回 state_conflict', () => { expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'open_expire')).toMatchObject({ ok: false, code: 'state_conflict' }) expect(run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'hold_timeout')).toMatchObject({ ok: false, code: 'state_conflict' }) diff --git a/tools/scripts/verify-local.sh b/tools/scripts/verify-local.sh index 8818114..cc5db82 100755 --- a/tools/scripts/verify-local.sh +++ b/tools/scripts/verify-local.sh @@ -30,6 +30,6 @@ curl -sf http://localhost:9201/healthz >/dev/null || { echo "API 未启动,日 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ pnpm --filter @voko/server verify:skeleton export VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 -for scenario in f0 f1 f2 concurrency timers; do +for scenario in f0 f1 f2 f3 concurrency timers; do pnpm --filter @voko/server "verify:$scenario" done -- libgit2 0.21.4