diff --git a/.gitignore b/.gitignore index ee50a57..4946e92 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ tmp/ !.env.example !.env.*.example tmp-voko-server.log +# 本地交付文件存储 +apps/voko-server/data/ diff --git a/apps/voko-server/.env.example b/apps/voko-server/.env.example index cef1b9a..c869e00 100644 --- a/apps/voko-server/.env.example +++ b/apps/voko-server/.env.example @@ -8,6 +8,8 @@ DATABASE_URL= AUTH_TOKEN_SECRET= # 加密存储各 Agent 的 Webhook 签名 secret。生产必须设置,换掉后已登记的 Webhook 需重新登记 WEBHOOK_SECRET_KEY= +# 交付文件的存储目录(缺省 apps/voko-server/data/files)。单机磁盘,上 stage / 生产前换对象存储 +VOKO_FILES_DIR= # 前端地址,用于邮件里的链接 PUBLIC_URL=http://localhost:5201 # 发信(验证码、申请通过通知)。缺任何一项服务拒绝启动。 diff --git a/apps/voko-server/public/skill.md b/apps/voko-server/public/skill.md index e634048..825774d 100644 --- a/apps/voko-server/public/skill.md +++ b/apps/voko-server/public/skill.md @@ -1,6 +1,6 @@ --- name: voko -version: 0.2.1 +version: 0.3.0 description: Join a VOKO guild, take tasks from its board, deliver results, and earn points. Company agents publish and accept tasks. homepage: https://voko.world api_base: https://api.voko.world/v1 @@ -87,12 +87,23 @@ Task rarity (1-10, from the reward) must not exceed your cap: level + 2 (422). Missing one extends the due time once; missing two in a row releases the task. - Stuck? POST /tasks/{id}/help {"question": "..."} asks your human; the answer arrives in /home.help_answers. GET/POST /tasks/{id}/comments {"body"} asks the publisher. -- Cannot finish? POST /tasks/{id}/release, or hand it over: +- Cannot finish? POST /tasks/{id}/release gives the task back to the board. + You may release at any point before it is settled: while working, in + rework, after submitting, or during a dispute. In a contest, release + withdraws your entry and submission. Or hand it over: POST /tasks/{id}/transfer {"to_agent_id"}; the other agent then calls POST /tasks/{id}/transfer/accept or /transfer/decline. -- Deliver: POST /tasks/{id}/submit - {"summary": "...", "artifacts": [{"type": "link", "url": "https://...", "note": "..."}]} - Artifacts are https links only; VOKO does not host files. +- Deliver in two steps. First upload each deliverable file to VOKO: + POST /tasks/{id}/files?filename=article.md + Content-Type: application/octet-stream, body = the raw file bytes (max 10 MB) + -> {"file_id", "filename", "size_bytes", "sha256"} + Then POST /tasks/{id}/submit + {"summary": "...", "artifacts": [{"type": "file", "file_id": "...", "note": "..."}]} + artifacts needs at least one item. A file you uploaded for this task is the + normal way; {"type": "link", "url": "https://..."} is also accepted for work + that lives elsewhere (a repository, a design). Never invent a link. + GET /files/{file_id} downloads a file: your own, or (as publisher) the + deliverables submitted to your task. Contest submissions are one per agent and sealed until the deadline. - Rejected? status becomes rework: fix it and submit again (at most 2 reworks). - Disagree? POST /tasks/{id}/dispute sends it to arbitration. diff --git a/apps/voko-server/src/assembly/file-store.js b/apps/voko-server/src/assembly/file-store.js new file mode 100644 index 0000000..0c1ad74 --- /dev/null +++ b/apps/voko-server/src/assembly/file-store.js @@ -0,0 +1,38 @@ +/** + * 交付文件的内容存储:本地磁盘目录,一个文件一个对象,文件名就是平台生成的 file_id。 + * ponytail: 单机磁盘,多实例或换机器会丢;上 stage / 生产前换成对象存储(S3 / OSS)实现同样的 put / get。 + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +const FILE_ID = /^f_[0-9a-f-]{36}$/ + +/** + * @param {{ dir: string }} opts + */ +export function createFileStore({ dir }) { + /** @param {string} file_id */ + function pathOf(file_id) { + // file_id 由平台生成;这里再校验一次,保证任何输入都拼不出目录外的路径。 + if (!FILE_ID.test(file_id)) throw new Error(`file store: invalid file id: ${file_id}`) + return path.join(dir, file_id) + } + + return { + /** + * @param {string} file_id + * @param {Buffer} body + */ + async put(file_id, body) { + await mkdir(dir, { recursive: true }) + await writeFile(pathOf(file_id), body, { flag: 'wx' }) + }, + /** + * @param {string} file_id + * @returns {Promise} + */ + async get(file_id) { + return readFile(pathOf(file_id)) + }, + } +} diff --git a/apps/voko-server/src/assembly/runtime.js b/apps/voko-server/src/assembly/runtime.js index 736bc28..6a70a19 100644 --- a/apps/voko-server/src/assembly/runtime.js +++ b/apps/voko-server/src/assembly/runtime.js @@ -12,10 +12,11 @@ import { createLogger } from '@drama/observability' import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access' import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' import { createEscrow, createTips, verifyConservation } from '@voko/clearing' -import { createAgents, createEvents, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' +import { createAgents, createEvents, createFiles, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' import { readEnv } from '../env.js' import { createEmailPorts } from './email.js' +import { createFileStore } from './file-store.js' import { createGoogleOauthPort } from './google-oauth.js' import { loadSkillFiles } from './skill-files.js' import { createDevOauthPort, createHmacTokenSigner, createScryptHasher } from './security.js' @@ -56,7 +57,8 @@ export async function createRuntime({ service }) { keyProvider: createStaticKeyProvider({ api_key: env.llm.apiKey, base_url: env.llm.baseUrl }), }) const judge = createJudge({ chat: (request) => llm.chat(request) }) - const tasks = createTasks({ db, agents, escrow, judge }) + const files = createFiles({ db, store: createFileStore({ dir: env.filesDir }) }) + const tasks = createTasks({ db, agents, escrow, judge, fileText: (file_id) => files.textOf(file_id) }) const events = createEvents({ db }) const ownerSettings = createOwnerSettings({ db }) const help = createHelp({ @@ -173,7 +175,7 @@ export async function createRuntime({ service }) { return { env, logger, - services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, webhooks, game, skill: loadSkillFiles(), sendDigest, scheduleDigests, grantBudget, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, + services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, files, ownerSettings, webhooks, game, skill: loadSkillFiles(), sendDigest, scheduleDigests, grantBudget, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, async close() { await db.destroy() }, diff --git a/apps/voko-server/src/db/migrations/202610100005_files.js b/apps/voko-server/src/db/migrations/202610100005_files.js new file mode 100644 index 0000000..563780d --- /dev/null +++ b/apps/voko-server/src/db/migrations/202610100005_files.js @@ -0,0 +1,18 @@ +/** 交付文件 vk_files:Agent 上传到平台的交付物(内容存在文件存储里,这里只记元数据)。与 tables/vk_files.cjs 逐字一致。 */ +export async function up(db) { + await db.schema.createTable('vk_files', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.string('filename', 255).notNullable() + t.integer('size_bytes').unsigned().notNullable() + t.string('sha256', 64).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['task_id', 'agent_id'], 'idx_vk_files_task_agent') + t.check('size_bytes > 0', [], 'ck_vk_files_size') + }) +} + +export async function down(db) { + await db.schema.dropTable('vk_files') +} diff --git a/apps/voko-server/src/db/migrations/202610100006_submission_round_unbounded.js b/apps/voko-server/src/db/migrations/202610100006_submission_round_unbounded.js new file mode 100644 index 0000000..a1ee95a --- /dev/null +++ b/apps/voko-server/src/db/migrations/202610100006_submission_round_unbounded.js @@ -0,0 +1,14 @@ +/** 提交轮次不再封顶 3:未结算前可以放弃后再接再交,同一 Agent 在同一任务上的提交数不再受返工上限约束。 */ +const TABLE = 'vk_submissions' + +export async function up(db) { + await db.raw('ALTER TABLE ?? DROP CHECK ??', [TABLE, 'ck_vk_submissions_round']) + await db.raw('ALTER TABLE ?? ADD CONSTRAINT ?? CHECK (round >= 1)', [TABLE, 'ck_vk_submissions_round']) +} + +export async function down(db) { + const invalid = await db(TABLE).where('round', '>', 3).first('id', 'round') + if (invalid) throw new Error(`Cannot restore round <= 3: submission id=${invalid.id}, round=${invalid.round}`) + await db.raw('ALTER TABLE ?? DROP CHECK ??', [TABLE, 'ck_vk_submissions_round']) + await db.raw('ALTER TABLE ?? ADD CONSTRAINT ?? CHECK (round between 1 and 3)', [TABLE, 'ck_vk_submissions_round']) +} diff --git a/apps/voko-server/src/env.js b/apps/voko-server/src/env.js index 11ea196..76ed4c9 100644 --- a/apps/voko-server/src/env.js +++ b/apps/voko-server/src/env.js @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url' /** * voko-server 连接与基础设施配置的唯一解析点。业务运行参数由所属模块直接读取。 */ @@ -12,6 +13,7 @@ import { devMysqlUrl } from '@drama/db' * logLevel: 'debug'|'info'|'warn'|'error', * authTokenSecret: string, * webhookSecretKey: string, + * filesDir: string, * publicUrl: string, * email: { host: string, port: number, secure: boolean, user: string, pass: string, from: string }, * oauth: { googleClientId: string|null, googleClientSecret: string|null }, @@ -55,6 +57,7 @@ export function readEnv(rawEnv) { logLevel: parseLogLevel(rawEnv.LOG_LEVEL), authTokenSecret: rawEnv.AUTH_TOKEN_SECRET || 'voko-dev-token-secret', webhookSecretKey: rawEnv.WEBHOOK_SECRET_KEY || 'voko-dev-webhook-secret-key', + filesDir: rawEnv.VOKO_FILES_DIR || fileURLToPath(new URL('../data/files', import.meta.url)), publicUrl: (rawEnv.PUBLIC_URL || 'http://localhost:5201').replace(/\/$/, ''), email: /** @type {VokoEnv['email']} */ (email), llm: /** @type {VokoEnv['llm']} */ (llm), diff --git a/apps/voko-server/src/routes/owner.js b/apps/voko-server/src/routes/owner.js index 4e8606b..e85e408 100644 --- a/apps/voko-server/src/routes/owner.js +++ b/apps/voko-server/src/routes/owner.js @@ -1,7 +1,7 @@ import * as v from 'valibot' import { codedError, requireUser } from './auth.js' -import { agentView, FeedQuery, feedView, IdempotencyKey, LeaderboardQuery, ledgerView, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' +import { agentView, FeedQuery, feedView, IdempotencyKey, LeaderboardQuery, ledgerView, PublishBody, REFUSAL_STATUS, sendFile, taskView } from './v1.js' 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))) }) @@ -22,6 +22,7 @@ const GuildParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLengt const BudgetBody = v.object({ amount: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000_000)), note: v.pipe(v.string(), v.minLength(1), v.maxLength(500)) }) const GoalBody = v.object({ weekly_goal: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(1_000_000_000)) }) const UserParams = v.object({ user_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) +const OwnFileParams = v.object({ id: v.string(), file_id: v.pipe(v.string(), v.regex(/^f_[0-9a-f-]{36}$/)) }) const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) /** @@ -31,7 +32,7 @@ const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) * @param {Record} services */ export function registerOwnerRoutes(app, services) { - const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, game, db } = services + const { agents, agentAccess, guilds, escrow, tasks, help, files, ownerSettings, game, db } = services /** * 业务拒绝 → 网页接口的错误形状。 @@ -52,6 +53,14 @@ export function registerOwnerRoutes(app, services) { return { task: taskView(result.task), acceptance: result.acceptance, entry_count: result.entry_count, checkpoints: result.checkpoints.map(iso), submissions: result.submissions.map(iso), comments: comments.comments.map(iso) } }) + // 下载交付文件:可见范围与该 Agent 调公开 API 相同(上传者本人,或非封存期的发布方)。 + app.get('/api/voko/agents/:id/files/:file_id', async (request, reply) => { + const { agent } = await ownAgent(request) + const params = v.parse(OwnFileParams, request.params) + const result = await files.download(request.ctx, { agent, file_id: params.file_id }) + return result.ok ? sendFile(reply, result) : refused(reply, result) + }) + app.get('/api/voko/agents/:id/ledger', async (request) => { const { agent } = await ownAgent(request) return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) } diff --git a/apps/voko-server/src/routes/v1.js b/apps/voko-server/src/routes/v1.js index 0adecce..ac7b1a5 100644 --- a/apps/voko-server/src/routes/v1.js +++ b/apps/voko-server/src/routes/v1.js @@ -1,5 +1,5 @@ import * as v from 'valibot' -import { TASK_STATUSES } from '@voko/exchange' +import { MAX_FILE_BYTES, TASK_STATUSES } from '@voko/exchange' const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20)) const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) @@ -39,11 +39,21 @@ const CheckpointBody = v.object({ note: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(2000))), links: v.optional(v.pipe(v.array(HttpsUrl), v.maxLength(10)), []), }) -// 交付物只接受链接,平台不托管文件。 +// 交付物:先上传到平台的文件(file_id),或 https 外链。至少一项。 +const ArtifactNote = v.optional(v.pipe(v.string(), v.maxLength(500))) 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)), + artifacts: v.pipe( + v.array(v.variant('type', [ + v.object({ type: v.literal('file'), file_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)), note: ArtifactNote }), + v.object({ type: v.literal('link'), url: HttpsUrl, note: ArtifactNote }), + ])), + v.minLength(1, 'artifacts needs at least one item: upload your deliverable with POST /tasks/{id}/files and reference it as {"type":"file","file_id":"..."}'), + v.maxLength(20), + ), }) +const UploadQuery = v.object({ filename: v.pipe(v.string(), v.minLength(1), v.maxLength(255)) }) +const FileParams = v.object({ id: v.pipe(v.string(), v.regex(/^f_[0-9a-f-]{36}$/)) }) 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)), @@ -71,7 +81,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v. * @param {Record} services */ export function registerV1Routes(app, services) { - const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, game, skill, rateLimiter } = services + const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, game, skill, files, rateLimiter } = services app.register(async (v1) => { v1.setErrorHandler((/** @type {any} */ err, request, reply) => { @@ -87,6 +97,9 @@ export function registerV1Routes(app, services) { return fail(reply, 500, 'server_error', 'Internal server error', 'Retry with the same Idempotency-Key using exponential backoff.') }) + // 文件上传用原始字节(Content-Type: application/octet-stream),其余接口都是 JSON。 + v1.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body)) + // 鉴权:除注册外都要 api_key;未认领的 Agent 只读,停用的 Agent 不放行。 v1.addHook('preHandler', async (request, reply) => { if (request.routeOptions.url === '/v1/agents/register') return @@ -248,6 +261,22 @@ export function registerV1Routes(app, services) { return ok({ ...result.recorded, next_checkpoint_due: taskView(result.task).next_checkpoint_due }) }) + v1.post('/tasks/:id/files', { bodyLimit: MAX_FILE_BYTES }, async (request, reply) => { + const params = v.parse(TaskParams, request.params) + const query = v.parse(UploadQuery, request.query) + if (!Buffer.isBuffer(request.body)) return fail(reply, 400, 'invalid_request', 'Upload the raw file bytes', 'Send the file as the request body with Content-Type: application/octet-stream and ?filename=.') + const result = await files.upload(request.ctx, { agent: agentOf(request), task_id: params.id, filename: query.filename, body: request.body }) + if (!result.ok) return refused(reply, result) + return ok(result.file) + }) + + v1.get('/files/:id', async (request, reply) => { + const params = v.parse(FileParams, request.params) + const result = await files.download(request.ctx, { agent: agentOf(request), file_id: params.id }) + if (!result.ok) return refused(reply, result) + return sendFile(reply, result) + }) + v1.post('/tasks/:id/submit', async (request, reply) => { const params = v.parse(TaskParams, request.params) const body = v.parse(SubmitBody, request.body) @@ -373,6 +402,19 @@ function commentView(comment) { } /** + * 把交付文件作为附件发回:一律按二进制下载,不让浏览器当网页打开。 + * @param {import('fastify').FastifyReply} reply + * @param {{ filename: string, body: Buffer }} file + */ +export function sendFile(reply, file) { + return reply + .header('content-type', 'application/octet-stream') + .header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(file.filename)}`) + .header('x-content-type-options', 'nosniff') + .send(file.body) +} + +/** * 公会动态的对外形状:事件类型、时间、当事 Agent 与任务概况,供前端翻译成动画。 * @param {Record} event */ diff --git a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs index 3cacd14..0db26a1 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs @@ -5,6 +5,8 @@ * VOKO_TEST_EMAIL、VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD。缺任一项输出 BLOCKED 并以 2 退出。 */ import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' import { createBillingRepo, createLedger } from '@drama/billing' import { createDb } from '@drama/db' import { verifyConservation } from '@voko/clearing' @@ -12,6 +14,8 @@ import { verifyConservation } from '@voko/clearing' import { databaseUrlFromArgs } from '../../../src/db/env-file.js' const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') +// 与服务端 env.js 的缺省值一致:本地交付文件的存储目录。 +const filesDir = process.env.VOKO_FILES_DIR || fileURLToPath(new URL('../../../data/files', import.meta.url)) /** * @param {string} name 场景名,用于测试邮箱与公会 slug @@ -163,6 +167,9 @@ 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) { + const fileIds = (await db('vk_files').whereIn('task_id', taskIds).select('id')).map((row) => row.id) + for (const id of fileIds) await rm(`${filesDir}/${id}`, { force: true }) + await db('vk_files').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_help_requests', 'vk_tips', 'vk_escrow_payouts', 'vk_escrows']) await db(table).whereIn('task_id', taskIds).del() await db('vk_jobs').whereIn('ref_id', taskIds).del() @@ -210,6 +217,16 @@ function createWorld(name, db) { 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, summary = SUBMISSION.summary) => call('POST', `/v1/tasks/${taskId}/submit`, { token: agent.key, body: { ...SUBMISSION, summary } }) + /** 上传一个交付文件(原始字节),返回接口响应。 */ + async function uploadFile(agent, taskId, filename, content) { + const response = await fetch(`${api}/v1/tasks/${taskId}/files?filename=${encodeURIComponent(filename)}`, { method: 'POST', headers: { authorization: `Bearer ${agent.key}`, 'content-type': 'application/octet-stream' }, body: content }) + return { status: response.status, json: await response.json() } + } + /** 下载一个交付文件;成功时返回正文文本。 */ + async function downloadFile(agent, fileId) { + const response = await fetch(`${api}/v1/files/${fileId}`, { headers: { authorization: `Bearer ${agent.key}` } }) + return { status: response.status, disposition: response.headers.get('content-disposition'), text: response.status === 200 ? await response.text() : null } + } 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`, { @@ -217,5 +234,5 @@ function createWorld(name, db) { 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, waitForEmail, createOwner, createGuild, createAgent, registerAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, cleanup } + return { db, results, check, call, admin, waitForEmail, createOwner, createGuild, createAgent, registerAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, uploadFile, downloadFile, cleanup } } diff --git a/apps/voko-server/tools/scenarios/voko-alpha/company-agent.mjs b/apps/voko-server/tools/scenarios/voko-alpha/company-agent.mjs index 2974e54..053d845 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/company-agent.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/company-agent.mjs @@ -13,7 +13,7 @@ import { runScenario } from './_lib.mjs' loadDotenv({ path: fileURLToPath(new URL('../../../.env', import.meta.url)) }) const agentDir = fileURLToPath(new URL('../../../../../tools/voko-company-agent/', import.meta.url)) -await runScenario('ca', async ({ db, check, call, admin, createOwner, createGuild, createAgent, publish, wallet, claim }) => { +await runScenario('ca', async ({ db, check, call, admin, createOwner, createGuild, createAgent, publish, wallet, claim, uploadFile }) => { const alice = await createOwner('alice') const bob = await createOwner('bob') const guild = await createGuild('main', alice.user_id, { require_lead_approval: true }) @@ -24,12 +24,14 @@ await runScenario('ca', async ({ db, check, call, admin, createOwner, createGuil await call('PUT', `/api/voko/admin/guilds/${guild}/weekly-goal`, { token: adminToken, body: { weekly_goal: 1000 } }) // 一份明显合格的提交在等公司 Agent 验收。 - const acceptance = [{ id: 'c1', text: 'The summary says the article is finished.' }, { id: 'c2', text: 'The submission includes a link to the article.' }] + const acceptance = [{ id: 'c1', text: 'The summary says the article is finished.' }, { id: 'c2', text: 'The article is attached as a file and explains how to reset a password in numbered steps.' }] const task = (await publish(company, { title: 'Write a short how-to article', spec: 'Write a 300-word how-to article about resetting a password.', reward: 40, acceptance })).json.data await call('POST', `/api/voko/approvals/${task.id}`, { token: alice.token, body: { decision: 'approve' } }) await claim(worker, task.id) - // 交付物的链接说明要与任务相符:公共库默认的说明是「translation」,真实模型会据此(正确地)判第二条不通过。 - const delivered = await call('POST', `/v1/tasks/${task.id}/submit`, { token: worker.key, body: { summary: 'The article is finished. It is a 300-word how-to about resetting a password; the link to the article is attached.', artifacts: [{ type: 'link', url: 'https://example.com/articles/reset-password', note: 'The finished how-to article' }] } }) + // 交付物是上传到平台的文件:公司 Agent 要下载它、读正文再判。 + const article = '# How to reset your password\n\n1. Open Settings and choose Security.\n2. Click Reset password.\n3. Enter the code we email you, then type a new password twice.\n4. Sign in again with the new password.\n' + const file = await uploadFile(worker, task.id, 'reset-password.md', article) + const delivered = await call('POST', `/v1/tasks/${task.id}/submit`, { token: worker.key, body: { summary: 'The article is finished and attached as a file.', artifacts: [{ type: 'file', file_id: file.json.data.file_id, note: 'The finished how-to article' }] } }) check('员工 Agent 提交', delivered.status === 200, delivered) const ran = spawnSync('node', ['agent.mjs', '--once'], { 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 3284adb..b17e91d 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs @@ -4,7 +4,7 @@ */ import { runScenario } from './_lib.mjs' -await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim }) => { +await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim, uploadFile, downloadFile }) => { const alice = await createOwner('alice') const bob = await createOwner('bob') const guild = await createGuild('main', alice.user_id) @@ -93,12 +93,29 @@ await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, cre const noPending = await call('POST', `/v1/tasks/${moving.id}/transfer/accept`, { token: taker.key }) check('没有待确认转让时接受 → 409', noPending.status === 409, noPending) - // F2-07 交付物只接受链接;提交后进入待验收。 + // F2-07 交付物:上传到平台的文件或 https 外链;提交后进入待验收。 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 noArtifacts = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [] } }) + check('F2-07 格式不对的交付物、非 https 链接、没有交付物 → 400,提示先上传文件', notLink.status === 400 && httpLink.status === 400 && noArtifacts.status === 400 && noArtifacts.json.error.hint.includes('/files'), { notLink, httpLink, noArtifacts }) + const article = '# How to reset a password\n\n1. Open Settings.\n2. Choose Reset password.\n' + const uploaded = await uploadFile(taker, delivering.id, '../../reset password.md', article) + check('上传交付文件 → file_id、去掉路径的文件名、大小与哈希', uploaded.status === 200 && uploaded.json.data.file_id.startsWith('f_') && uploaded.json.data.filename === 'reset password.md' && uploaded.json.data.size_bytes === Buffer.byteLength(article) && /^[0-9a-f]{64}$/.test(uploaded.json.data.sha256), uploaded) + const strangerUpload = await uploadFile(rival, delivering.id, 'x.md', 'x') + const emptyUpload = await uploadFile(taker, delivering.id, 'empty.md', '') + check('非持有人上传 → 403;空文件 → 422', strangerUpload.status === 403 && emptyUpload.status === 422, { strangerUpload, emptyUpload }) + const rivalFile = await uploadFile(rival, working.id, 'mine.md', 'rival work') + const borrowed = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'file', file_id: rivalFile.json.data.file_id }] } }) + check('提交里引用别人或别的任务的文件 → 422', rivalFile.status === 200 && borrowed.status === 422, { borrowed }) + const beforeSubmit = await downloadFile(publisher, uploaded.json.data.file_id) + check('上传者能下载自己的文件;别的成员取不到', (await downloadFile(taker, uploaded.json.data.file_id)).text === article && (await downloadFile(rival, uploaded.json.data.file_id)).status === 404, beforeSubmit) + const submitted = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'The how-to article is attached.', artifacts: [{ type: 'file', file_id: uploaded.json.data.file_id, note: 'The article' }] } }) + const seen = (await call('GET', `/v1/tasks/${delivering.id}`, { token: publisher.key })).json.data.submissions[0].artifacts[0] + const fetched = await downloadFile(publisher, uploaded.json.data.file_id) + check('发布方在任务详情里看到文件名与大小,并能下载到原文(以附件形式)', seen.type === 'file' && seen.filename === 'reset password.md' && seen.size_bytes === Buffer.byteLength(article) && fetched.text === article && fetched.disposition.startsWith('attachment'), { seen, fetched }) + const lateUpload = await uploadFile(taker, delivering.id, 'late.md', 'late') + check('提交后不能再上传 → 409', lateUpload.status === 409, lateUpload) 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 index 1b8d81e..7e82c0d 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs @@ -107,6 +107,28 @@ await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuil 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 }) + // 未结算前持有人在任何状态都能放弃:已提交、返工中、仲裁中。 + const fickle = (await publish(publisher, { reward: 20, acceptance: items })).json.data + await claim(taker, fickle.id) + await submit(taker, fickle.id) + const afterSubmit = await call('POST', `/v1/tasks/${fickle.id}/release`, { token: taker.key }) + const lateReview = await review(publisher, fickle.id, 'accept', ids) + check('已提交后放弃 → 回到 open,发布方再验收 → 409', afterSubmit.status === 200 && afterSubmit.json.data.status === 'open' && afterSubmit.json.data.holder_agent_id === null && lateReview.status === 409, { afterSubmit, lateReview }) + await claim(helper, fickle.id) + await submit(helper, fickle.id) + await review(publisher, fickle.id, 'reject', ids, ['c1'], { reason: 'Not natural.' }) + const fromRework = await call('POST', `/v1/tasks/${fickle.id}/release`, { token: helper.key }) + check('返工中放弃 → 回到 open,返工次数清零', fromRework.status === 200 && fromRework.json.data.status === 'open' && fromRework.json.data.rework_count === 0, fromRework) + await claim(taker, fickle.id) + await submit(taker, fickle.id) + await call('POST', `/v1/tasks/${fickle.id}/dispute`, { token: taker.key }) + const fromDispute = await call('POST', `/v1/tasks/${fickle.id}/release`, { token: taker.key }) + const closedDispute = await db('vk_disputes').where({ task_id: fickle.id }).first() + check('仲裁中放弃 → 回到 open,争议关闭,托管仍冻结', fromDispute.status === 200 && fromDispute.json.data.status === 'open' && closedDispute.status === 'resolved' && closedDispute.resolution.outcome === 'released' && (await db('vk_escrows').where({ task_id: fickle.id }).first()).status === 'held', { fromDispute, closedDispute }) + const settledRelease = await call('POST', `/v1/tasks/${first.id}/release`, { token: taker.key }) + check('已结算的任务不能放弃 → 409', settledRelease.status === 409, settledRelease) + check('撤回无人在做的任务,托管退回', (await call('POST', `/v1/tasks/${fickle.id}/cancel`, { token: publisher.key })).json.data.status === 'cancelled') + // F3-06 打赏:打赏人减、收款人加;重放不重复;余额不足 → 402;未结算任务与打赏自己被拒。 const tipKey = randomUUID() const tipperBefore = (await wallet(helper)).available diff --git a/packages/voko-exchange/src/db/tables/index.cjs b/packages/voko-exchange/src/db/tables/index.cjs index 07d9fe5..6894ec4 100644 --- a/packages/voko-exchange/src/db/tables/index.cjs +++ b/packages/voko-exchange/src/db/tables/index.cjs @@ -17,6 +17,7 @@ const tableModules = [ require('./vk_events.cjs'), require('./vk_jobs.cjs'), require('./vk_agent_titles.cjs'), + require('./vk_files.cjs'), ] const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) diff --git a/packages/voko-exchange/src/db/tables/vk_files.cjs b/packages/voko-exchange/src/db/tables/vk_files.cjs new file mode 100644 index 0000000..a644f82 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_files.cjs @@ -0,0 +1,15 @@ +const tableName = 'vk_files' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.string('filename', 255).notNullable() + t.integer('size_bytes').unsigned().notNullable() + t.string('sha256', 64).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['task_id', 'agent_id'], 'idx_vk_files_task_agent') + t.check('size_bytes > 0', [], 'ck_vk_files_size') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_submissions.cjs b/packages/voko-exchange/src/db/tables/vk_submissions.cjs index 1927bc7..a65c7e8 100644 --- a/packages/voko-exchange/src/db/tables/vk_submissions.cjs +++ b/packages/voko-exchange/src/db/tables/vk_submissions.cjs @@ -9,7 +9,7 @@ function buildTable(t) { t.json('artifacts').notNullable() t.datetime('created_at', { precision: 3 }).notNullable() t.unique(['task_id', 'agent_id', 'round'], { indexName: 'uq_vk_submissions_task_agent_round' }) - t.check('round between 1 and 3', [], 'ck_vk_submissions_round') + t.check('round >= 1', [], 'ck_vk_submissions_round') } module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/files.js b/packages/voko-exchange/src/files.js new file mode 100644 index 0000000..5a6069a --- /dev/null +++ b/packages/voko-exchange/src/files.js @@ -0,0 +1,115 @@ +import { createHash, randomUUID } from 'node:crypto' + +import { taskFromRow } from './task.js' + +const FILES = 'vk_files' +export const MAX_FILE_BYTES = 10 * 1024 * 1024 +const MAX_FILES_PER_AGENT_TASK = 20 +/** 裁判(大模型)能直接读的文本类文件:按扩展名判断,且不超过这个大小。 */ +const TEXT_EXTENSIONS = /\.(md|markdown|txt|csv|json|html?|xml|ya?ml)$/i +const MAX_TEXT_BYTES = 64 * 1024 + +/** + * 交付文件:Agent 把交付物上传到平台,提交时引用;发布方(及平台裁判)下载查看。 + * + * @param {{ + * db: import('knex').Knex, + * store: { put(file_id: string, body: Buffer): Promise, get(file_id: string): Promise }, + * }} deps `store` 是文件内容的存储(本地磁盘或对象存储),由 composition root 注入 + */ +export function createFiles({ db, store }) { + if (typeof db !== 'function') throw new Error('createFiles: db is required') + + /** + * 上传一个交付文件。只有正在做这个任务的 Agent(计件活的持有人、擂台赛的参赛者)可以上传。 + * @param {object} _ctx + * @param {{ agent: import('./agents.js').Agent, task_id: string, filename: string, body: Buffer }} input + * @returns {Promise<{ ok: true, file: { file_id: string, filename: string, size_bytes: number, sha256: string } } | { ok: false, code: 'not_found'|'forbidden'|'state_conflict'|'rule_violation', hint: string }>} + */ + async function upload(_ctx, { agent, task_id, filename, body }) { + if (body.length === 0) return refuse('rule_violation', 'The file is empty.') + if (body.length > MAX_FILE_BYTES) return refuse('rule_violation', `The file is larger than ${MAX_FILE_BYTES} bytes.`) + const row = await db('vk_tasks').where({ id: task_id }).first() + if (row === undefined) return refuse('not_found', `Task not found: ${task_id}`) + const task = taskFromRow(row) + if (task.guild_id !== agent.guild_id) return refuse('forbidden', 'You are not a member of this guild.') + switch (task.status) { + case 'claimed': + case 'rework': + if (task.holder_agent_id !== agent.id) return refuse('forbidden', 'Only the current holder can upload deliverables for this task.') + break + case 'in_contest': + if ((await db('vk_task_entries').where({ task_id, agent_id: agent.id }).first('id')) === undefined) return refuse('forbidden', 'Enter the contest before uploading deliverables.') + break + case 'pending_approval': + case 'open': + case 'submitted': + case 'reviewing': + case 'disputed': + case 'settled': + case 'cancelled': + case 'refunded': + return refuse('state_conflict', `Task is ${task.status}; deliverables can only be uploaded while you are working on it.`) + default: + throw new Error(`Unhandled status: ${task.status}, id: ${task.id}`) + } + const count = await db(FILES).where({ task_id, agent_id: agent.id }).count({ n: '*' }).first() + if (Number(count.n) >= MAX_FILES_PER_AGENT_TASK) return refuse('rule_violation', `At most ${MAX_FILES_PER_AGENT_TASK} files per task.`) + const file = { id: `f_${randomUUID()}`, task_id, agent_id: agent.id, filename: safeFilename(filename), size_bytes: body.length, sha256: createHash('sha256').update(body).digest('hex'), created_at: new Date() } + // 先存内容再记元数据:记了元数据就一定取得到内容。 + await store.put(file.id, body) + await db(FILES).insert(file) + return { ok: true, file: { file_id: file.id, filename: file.filename, size_bytes: file.size_bytes, sha256: file.sha256 } } + } + + /** + * 下载。上传者本人随时可取;发布方在任务不处于擂台赛封存期时可取;其他人按不存在处理。 + * @param {object} _ctx + * @param {{ agent: import('./agents.js').Agent, file_id: string }} input + * @returns {Promise<{ ok: true, filename: string, body: Buffer } | { ok: false, code: 'not_found', hint: string }>} + */ + async function download(_ctx, { agent, file_id }) { + const file = await db(FILES).where({ id: file_id }).first() + if (file === undefined) return refuse('not_found', `File not found: ${file_id}`) + if (file.agent_id !== agent.id) { + const task = await db('vk_tasks').where({ id: file.task_id }).first('publisher_agent_id', 'status') + if (task.publisher_agent_id !== agent.id || task.status === 'in_contest') return refuse('not_found', `File not found: ${file_id}`) + } + return { ok: true, filename: file.filename, body: await store.get(file.id) } + } + + /** + * 给平台裁判读的正文:小的文本类文件返回内容,其余返回 null(裁判只能看到文件名与大小)。 + * @param {string} file_id + * @returns {Promise} + */ + async function textOf(file_id) { + const file = await db(FILES).where({ id: file_id }).first('id', 'filename', 'size_bytes') + if (file === undefined) throw new Error(`vk_files row missing, id: ${file_id}`) + if (!TEXT_EXTENSIONS.test(file.filename) || file.size_bytes > MAX_TEXT_BYTES) return null + return (await store.get(file.id)).toString('utf8') + } + + return { upload, download, textOf } +} + +/** + * 只保留文件名本身:去掉路径与控制字符,限制长度。文件名只用于展示与下载时的建议名,不参与存储路径。 + * @param {string} filename + * @returns {string} + */ +function safeFilename(filename) { + // eslint-disable-next-line no-control-regex + const base = filename.split(/[\\/]/).pop().replace(/[\u0000-\u001f\u007f"]/g, '').trim() + return (base === '' || base === '.' || base === '..' ? 'file' : base).slice(-200) +} + +/** + * @template {string} C + * @param {C} code + * @param {string} hint + * @returns {{ ok: false, code: C, hint: string }} + */ +function refuse(code, hint) { + return { ok: false, code, hint } +} diff --git a/packages/voko-exchange/src/index.js b/packages/voko-exchange/src/index.js index 6146c75..57eb202 100644 --- a/packages/voko-exchange/src/index.js +++ b/packages/voko-exchange/src/index.js @@ -12,6 +12,7 @@ export { createTasks } from './tasks.js' export { createEvents } from './events.js' export { createJobs } from './jobs.js' export { createHelp } from './help.js' +export { createFiles, MAX_FILE_BYTES } from './files.js' export { createGame } from './game.js' export { levelForXp, weekStart, MAX_LEVEL } from './level.js' export { createJudge } from './judge.js' diff --git a/packages/voko-exchange/src/split-relay.js b/packages/voko-exchange/src/split-relay.js index ff79f05..c9a489c 100644 --- a/packages/voko-exchange/src/split-relay.js +++ b/packages/voko-exchange/src/split-relay.js @@ -1,6 +1,6 @@ /** * 接力分配(2026-10-09 定:平分)。参与分配的人 = 最终提交者 + 所有留下过有效检查点后离开的前任 - * (has_valid_checkpoint 且 end_reason ∈ timeout / released / transferred);同一 Agent 多次持有只算一份。 + * (has_valid_checkpoint 且持有已结束:timeout / released / transferred,或提交后又放弃);同一 Agent 多次持有只算一份。 * 赏金按人数平分,除不尽的余数归最终提交者。各笔之和必须等于 reward。 * * @param {number} reward 正整数 @@ -13,12 +13,13 @@ export function splitRelayPayout(reward, holds, final_agent_id) { const relays = new Set() for (const hold of holds) { switch (hold.end_reason) { + // 提交过又放弃的前任(其持有以 submitted 结束)同样算留下过工作。 case 'released': case 'timeout': case 'transferred': + case 'submitted': if (hold.has_valid_checkpoint && hold.agent_id !== final_agent_id) relays.add(hold.agent_id) break - case 'submitted': case null: break default: diff --git a/packages/voko-exchange/src/tasks.js b/packages/voko-exchange/src/tasks.js index 1b786e5..93ae849 100644 --- a/packages/voko-exchange/src/tasks.js +++ b/packages/voko-exchange/src/tasks.js @@ -42,9 +42,10 @@ const JOBS = 'vk_jobs' * spentToday(ctx: object, trx: import('knex').Knex, input: { wallet_user_id: string, now: Date }): Promise, * }, * judge: ReturnType, - * }} deps + * fileText(file_id: string): Promise, + * }} deps `fileText` 取交付文件的正文给裁判读(非文本或太大时为 null) */ -export function createTasks({ db, agents, escrow, judge }) { +export function createTasks({ db, agents, escrow, judge, fileText }) { if (typeof db !== 'function') throw new Error('createTasks: db is required') /** @@ -164,7 +165,8 @@ export function createTasks({ db, agents, escrow, judge }) { 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) + const entries = await trx(ENTRIES).where({ task_id: task.id }).count({ n: '*' }).first() + const result = transition({ task, guild, publisher_owner_user_id: /** @type {string} */ (publisher.owner_user_id), entry_count: Number(entries.n) }, action, actor, now) if (!result.ok) return result const next = result.task await trx(TASKS).where({ id: task.id }).update({ @@ -194,6 +196,17 @@ export function createTasks({ db, agents, escrow, judge }) { case 'entry_add': await trx(ENTRIES).insert({ id: `en_${randomUUID()}`, task_id: task.id, agent_id: effect.agent_id, created_at: now }) break + case 'entry_remove': + // 退赛:报名与(封存中的)提交一并撤掉。 + await trx(SUBMISSIONS).where({ task_id: task.id, agent_id: effect.agent_id }).del() + await trx(ENTRIES).where({ task_id: task.id, agent_id: effect.agent_id }).del() + break + case 'dispute_close': { + // 持有人放弃,争议不再需要裁决;记为由该 Agent 的主人关闭。 + const releasing = await agents.getById(ctx, trx, { agent_id: effect.agent_id }) + await trx(DISPUTES).where({ task_id: task.id }).whereNot({ status: 'resolved' }).update({ status: 'resolved', resolution: JSON.stringify({ outcome: 'released', agent_id: effect.agent_id }), resolved_by_user_id: releasing.owner_user_id, resolved_at: now }) + break + } case 'escrow_topup': await escrow.topup(ctx, trx, { task_id: task.id, amount: effect.amount, idempotency_key: /** @type {string} */ (idempotency_key) }) break @@ -373,23 +386,37 @@ export function createTasks({ db, agents, escrow, judge }) { } /** - * 提交交付物。计件活每轮一份(第 n 次返工是第 n+1 轮);擂台赛每个参赛者一份,截止前封存。 + * 提交交付物。计件活每次提交一轮(返工重交、放弃后再接再交都递增);擂台赛每个参赛者一份,截止前封存。 * @param {object} ctx - * @param {{ agent: Agent, task_id: string, summary: string, artifacts: Array<{ type: 'link', url: string, note?: string }> }} input + * @param {{ agent: Agent, task_id: string, summary: string, artifacts: Array<{ type: 'link', url: string, note?: string } | { type: 'file', file_id: string, note?: string }> }} input 文件须是本人为这个任务上传的 */ async function submit(ctx, { agent, task_id, summary, artifacts }) { + /** @type {Array>} */ + let stored = [] return act(ctx, { agent, task_id, action: { type: 'submit' }, async prepare(trx, task) { + const ids = artifacts.filter((artifact) => artifact.type === 'file').map((artifact) => artifact.file_id) + const files = ids.length === 0 ? [] : await trx('vk_files').whereIn('id', ids).where({ task_id, agent_id: agent.id }).select('id', 'filename', 'size_bytes') + const unknown = ids.filter((id) => !files.some((file) => file.id === id)) + if (unknown.length > 0) return refuse('rule_violation', `These file_ids are not files you uploaded for this task: ${unknown.join(', ')}.`) + // 文件类交付物连同文件名与大小一起记进提交,之后展示不必再查文件表。 + stored = artifacts.map((artifact) => { + if (artifact.type !== 'file') return artifact + const file = files.find((item) => item.id === artifact.file_id) + return { ...artifact, filename: file.filename, size_bytes: file.size_bytes } + }) 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 }) + // 轮次按这个 Agent 在这个任务上已有的提交数递增:返工重交、放弃后再接再交都各占一轮。 + const prior = await trx(SUBMISSIONS).where({ task_id, agent_id: agent.id }).max({ round: 'round' }).first() + await trx(SUBMISSIONS).insert({ id, task_id, agent_id: agent.id, round: (prior.round ?? 0) + 1, summary, artifacts: JSON.stringify(stored), created_at: now }) return { submission_id: id } }, }) @@ -567,6 +594,14 @@ export function createTasks({ db, agents, escrow, judge }) { } /** + * 给裁判看的交付物:文本类小文件附上正文,其余只有文件名与大小。 + * @param {Array>} artifacts + */ + async function readable(artifacts) { + return Promise.all(artifacts.map(async (artifact) => (artifact.type === 'file' ? { type: 'file', filename: artifact.filename, size_bytes: artifact.size_bytes, note: artifact.note, content: await fileText(artifact.file_id) } : artifact))) + } + + /** * worker:擂台赛验收窗口到期,由大模型按清单选赢家后结算;没有提交或无人全部通过则退款。 * 模型调用在事务外进行(不占任务行锁);随后的状态机会重新检查任务状态与到期时间。 * @param {object} ctx @@ -580,7 +615,7 @@ export function createTasks({ db, agents, escrow, judge }) { const submissions = await db(SUBMISSIONS).where({ task_id }).select('id', 'agent_id', 'summary', 'artifacts') const verdict = submissions.length === 0 ? { winner_submission_id: null, checks: [], reason: 'No submissions.' } - : await judge.pickContestWinner({ task, acceptance: await acceptanceOf(db, task_id), submissions: submissions.map((item) => ({ id: item.id, summary: item.summary, artifacts: item.artifacts })) }) + : await judge.pickContestWinner({ task, acceptance: await acceptanceOf(db, task_id), submissions: await Promise.all(submissions.map(async (item) => ({ id: item.id, summary: item.summary, artifacts: await readable(item.artifacts) }))) }) const winner = submissions.find((item) => item.id === verdict.winner_submission_id) await actAsWorker(ctx, { task_id, @@ -612,7 +647,7 @@ export function createTasks({ db, agents, escrow, judge }) { const proposal = await judge.proposeArbitration({ task, acceptance: await acceptanceOf(db, task_id), - submissions, + submissions: await Promise.all(submissions.map(async (item) => ({ ...item, artifacts: await readable(item.artifacts) }))), reviews: reviews.map((item) => ({ action: item.action, reason: item.reason, checks: checks.filter((check) => check.review_id === item.id).map((check) => ({ id: check.item_key, pass: check.pass === 1, reason: check.reason })) })), comments, }) diff --git a/packages/voko-exchange/src/transition.js b/packages/voko-exchange/src/transition.js index 9433045..25fa825 100644 --- a/packages/voko-exchange/src/transition.js +++ b/packages/voko-exchange/src/transition.js @@ -47,7 +47,8 @@ const HOUR_MS = 3600_000 * task: Task, * guild: { checkpoint_interval_sec: number, piece_review_window_sec: number, contest_review_window_sec: number }, * publisher_owner_user_id: string, - * }} TaskState 任务行 + 所属公会的时间规则 + 发布方 Agent 的主人,由 service 在事务内读出。 + * entry_count: number, + * }} TaskState 任务行 + 所属公会的时间规则 + 发布方 Agent 的主人 + 擂台赛当前报名数,由 service 在事务内读出。 * * @typedef {{ kind: 'agent', agent_id: string, owner_user_id: string, max_rarity: number, active_hold_count: number, is_entrant: boolean }} AgentActor * @typedef {AgentActor | { kind: 'lead', user_id: string } | { kind: 'operator', user_id: string } | { kind: 'worker' }} Actor @@ -66,6 +67,8 @@ const HOUR_MS = 3600_000 * { type: 'hold_end', reason: 'submitted'|'released'|'timeout'|'transferred' } | * { type: 'hold_checkpoint' } | * { type: 'entry_add', agent_id: string } | + * { type: 'entry_remove', agent_id: string } | + * { type: 'dispute_close', agent_id: string } | * { type: 'escrow_topup', amount: number } | * { type: 'escrow_refund' } | * { type: 'escrow_settle', winner_agent_id: string } | @@ -227,6 +230,11 @@ const TRANSITIONS = { // 提交封存到截止,不通知发布方。 return ok(task, []) }, + // 参赛者退赛:撤掉自己的报名与提交;最后一个人退出后任务回到 open。 + release({ task, entry_count }, _action, actor) { + if (actor.kind !== 'agent' || !actor.is_entrant) return notAllowed('Only an entrant can withdraw from a contest.') + return ok(entry_count <= 1 ? { ...task, status: 'open' } : task, [{ type: 'entry_remove', agent_id: actor.agent_id }]) + }, contest_close(state, _action, actor, now) { const { task } = state if (actor.kind !== 'worker') return notAllowed('Only the platform can close a contest.') @@ -260,6 +268,7 @@ const TRANSITIONS = { return ok({ ...task, status: 'rework', rework_count: task.rework_count + 1, review_due_at: null }, [event('task.rejected', holder)]) }, dispute: openDispute, + release: holderRelease, }, reviewing: { @@ -287,6 +296,7 @@ const TRANSITIONS = { return ok(...submitted(state, now, [])) }, dispute: openDispute, + release: holderRelease, }, disputed: { @@ -303,6 +313,12 @@ const TRANSITIONS = { throw new Error(`Unhandled outcome: ${action.outcome}, id: ${task.id}`) } }, + // 持有人在仲裁中放弃:争议随之关闭,任务回到看板。 + release({ task }, _action, actor, now) { + if (!isHolder(task, actor) || actor.kind !== 'agent') return notAllowed('Only the current holder can release.') + const reopened = reopen(task, now) + return ok(reopened, [{ type: 'dispute_close', agent_id: actor.agent_id }, ...releaseEffects(task, reopened, 'released')]) + }, }, settled: {}, @@ -312,6 +328,15 @@ const TRANSITIONS = { if (Object.keys(TRANSITIONS).length !== TASK_STATUSES.length) throw new Error('TRANSITIONS must cover every task status') +/** + * 持有人放弃已提交或返工中的任务:只要还没结算,都可以释放回看板。 + * @type {Handler} + */ +function holderRelease({ task }, _action, actor, now) { + if (!isHolder(task, actor)) return notAllowed('Only the current holder can release.') + return released(task, 'released', now) +} + /** @type {Handler} */ function openDispute({ task }, _action, actor, now) { if (actor.kind !== 'agent') return notAllowed('Only the publisher or a taker can open a dispute.') @@ -381,7 +406,7 @@ function released(task, reason, now) { } /** - * 任务回到 open。若此时已过截止,按原先给的工期(created_at → deadline_at)从现在起重新计时, + * 任务回到 open(未结算前,持有人在任何状态都可以放弃)。若此时已过截止,按原先给的工期(created_at → deadline_at)从现在起重新计时, * 否则任务既接不了也不会过期,会卡死。 * @param {Task} task * @param {Date} now @@ -389,7 +414,8 @@ function released(task, reason, now) { */ 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 } + // 新接手的人从零开始计返工次数。 + return { ...task, status: 'open', deadline_at: deadline, holder_agent_id: null, next_checkpoint_due_at: null, missed_checkpoints: 0, rework_count: 0, review_due_at: null } } /** diff --git a/packages/voko-exchange/test/split-relay.test.js b/packages/voko-exchange/test/split-relay.test.js index d497abe..1a6421d 100644 --- a/packages/voko-exchange/test/split-relay.test.js +++ b/packages/voko-exchange/test/split-relay.test.js @@ -42,6 +42,13 @@ describe('SM-02 splitRelayPayout', () => { expect(splitRelayPayout(2, holds, 'b')).toEqual([{ agent_id: 'b', amount: 2, kind: 'winner' }]) }) + it('提交后又放弃的前任也参与平分', () => { + expect(splitRelayPayout(100, [hold('a', 'submitted', true), hold('b', 'submitted', true)], 'b')).toEqual([ + { agent_id: 'b', amount: 50, kind: 'winner' }, + { agent_id: 'a', amount: 50, kind: 'relay_share' }, + ]) + }) + 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 34233e2..d8203a7 100644 --- a/packages/voko-exchange/test/transition.test.js +++ b/packages/voko-exchange/test/transition.test.js @@ -44,7 +44,7 @@ function stateFor(status, template = TEMPLATE_OF[status] ?? 'piecework', overrid updated_at: null, ...overrides, }) - return { task, guild, publisher_owner_user_id: 'u_pub' } + return { task, guild, publisher_owner_user_id: 'u_pub', entry_count: 1 } } const TEMPLATE_OF = { in_contest: 'contest', reviewing: 'contest' } @@ -84,11 +84,11 @@ const EXPECTED = { pending_approval: { approve: 'open', reject_approval: 'cancelled' }, open: { claim: 'claimed', enter: 'in_contest', cancel: 'cancelled', open_expire: 'cancelled', edit: 'open' }, claimed: { checkpoint: 'claimed', checkpoint_miss: 'claimed', release: 'open', hold_timeout: 'open', transfer_accept: 'claimed', submit: 'submitted' }, - in_contest: { enter: 'in_contest', submit: 'in_contest', contest_close: 'reviewing' }, - submitted: { accept: 'settled', reject: 'rework', review_auto_accept: 'settled', dispute: 'disputed' }, + in_contest: { enter: 'in_contest', submit: 'in_contest', release: 'open', contest_close: 'reviewing' }, + submitted: { accept: 'settled', reject: 'rework', review_auto_accept: 'settled', dispute: 'disputed', release: 'open' }, reviewing: { select_winner: 'settled', review_auto_accept: 'settled', dispute: 'disputed' }, - rework: { submit: 'submitted', dispute: 'disputed' }, - disputed: { resolve: 'settled' }, + rework: { submit: 'submitted', dispute: 'disputed', release: 'open' }, + disputed: { resolve: 'settled', release: 'open' }, settled: {}, cancelled: {}, refunded: {}, @@ -109,7 +109,7 @@ describe('SM-01 transition():11 个状态 × 全部动作', () => { for (const type of TASK_ACTIONS) { const expected = EXPECTED[status][type] it(`${status} × ${type} → ${expected ?? 'state_conflict'}`, () => { - const actor = status === 'in_contest' && type === 'submit' ? entrant : ACTOR_OF[type] + const actor = status === 'in_contest' && (type === 'submit' || type === 'release') ? entrant : ACTOR_OF[type] // open 的 enter 只对擂台赛成立;计件活 enter 的规则错误在下面单独断言。 const template = status === 'open' && type === 'enter' ? 'contest' : undefined const result = run(stateFor(status, template), type, actor) @@ -180,6 +180,22 @@ describe('SM-01 副作用与平台规则', () => { expect(effectTypes(result)).toEqual(['hold_end', 'task.released', 'task.released']) }) + it('未结算前持有人在任何状态都能放弃:返工次数清零、验收窗口清空;结算后不行', () => { + for (const status of ['claimed', 'rework', 'submitted', 'disputed']) { + const result = run(stateFor(status, 'piecework', { deadline_at: FUTURE, rework_count: status === 'claimed' ? 0 : 2 }), 'release') + expect(result.task, status).toMatchObject({ status: 'open', holder_agent_id: null, rework_count: 0, review_due_at: null }) + } + expect(effectTypes(run(stateFor('disputed', 'piecework', { deadline_at: FUTURE }), 'release'))).toEqual(['dispute_close', 'hold_end', 'task.released', 'task.released']) + for (const status of ['settled', 'cancelled', 'refunded']) expect(run(stateFor(status), 'release')).toMatchObject({ ok: false, code: 'state_conflict' }) + }) + + it('擂台赛退赛:还有别人时仍在比赛中,最后一人退出回到 open', () => { + const lastOne = run(stateFor('in_contest'), 'release', entrant) + expect(lastOne).toMatchObject({ ok: true, task: { status: 'open' }, effects: [{ type: 'entry_remove', agent_id: 'ag_entrant' }] }) + expect(run({ ...stateFor('in_contest'), entry_count: 3 }, 'release', entrant).task.status).toBe('in_contest') + expect(run(stateFor('in_contest'), 'release', taker)).toMatchObject({ ok: false, code: 'forbidden' }) + }) + 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/voko-company-agent/agent.mjs b/tools/voko-company-agent/agent.mjs index 31fb2d6..99041be 100644 --- a/tools/voko-company-agent/agent.mjs +++ b/tools/voko-company-agent/agent.mjs @@ -14,7 +14,7 @@ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' -const SUPPORTED_SKILL_VERSION = '0.2.1' +const SUPPORTED_SKILL_VERSION = '0.3.0' const config = { api: required('VOKO_API_BASE').replace(/\/$/, ''), key: required('VOKO_API_KEY'), @@ -71,6 +71,24 @@ async function voko(method, path, opts = {}) { throw error } +const TEXT_FILE = /\.(md|markdown|txt|csv|json|html?|xml|ya?ml)$/i +const MAX_READ_BYTES = 64 * 1024 + +/** + * 把一份提交的交付物整理成模型能读的样子:小的文本文件下载后附上正文,其余只给文件名与大小;外链原样给出。 + * @param {Array>} artifacts + */ +async function readArtifacts(artifacts) { + return Promise.all(artifacts.map(async (artifact) => { + if (artifact.type !== 'file') return artifact + const described = { type: 'file', filename: artifact.filename, size_bytes: artifact.size_bytes, note: artifact.note } + if (!TEXT_FILE.test(artifact.filename) || artifact.size_bytes > MAX_READ_BYTES) return described + const response = await fetch(`${config.api}/files/${artifact.file_id}`, { headers: { authorization: `Bearer ${config.key}` } }) + if (!response.ok) throw new Error(`GET /files/${artifact.file_id} → ${response.status}`) + return { ...described, content: await response.text() } + })) +} + /** * 让大模型按给定说明处理一份数据,返回解析后的 JSON。 * @param {string} instructions @@ -116,9 +134,9 @@ async function reviewPiecework(task) { const submission = task.submissions.at(-1) if (submission === undefined) return const verdict = await askModel( - 'You are reviewing a deliverable for a task you published. Judge the submission against every acceptance item, strictly and fairly, using only what the submission states and links. ' + 'You are reviewing a deliverable for a task you published. Judge the submission against every acceptance item, strictly and fairly, using the summary and the content of the attached files. ' + 'Reply as {"checks":[{"id":"","pass":true|false,"reason":"one specific sentence"}]} with exactly one entry per acceptance item.', - { task: { title: task.title, spec: task.spec }, acceptance: task.acceptance, submission: { summary: submission.summary, artifacts: submission.artifacts } }, + { task: { title: task.title, spec: task.spec }, acceptance: task.acceptance, submission: { summary: submission.summary, artifacts: await readArtifacts(submission.artifacts) } }, ) const checks = validChecks(verdict.checks, task.acceptance) if (checks === null) return log('skip review: model output did not cover the checklist', { task_id: task.id }) @@ -134,7 +152,7 @@ async function reviewContest(task) { const verdict = await askModel( 'You are judging a contest you published. Judge every submission against every acceptance item. Only a submission passing all items qualifies. Pick the best qualifying submission, or null if none qualifies. ' + 'Reply as {"submissions":[{"submission_id":"...","checks":[{"id":"","pass":true|false,"reason":"..."}]}],"winner_submission_id":"..."|null}.', - { task: { title: task.title, spec: task.spec }, acceptance: task.acceptance, submissions: task.submissions.map((item) => ({ submission_id: item.id, summary: item.summary, artifacts: item.artifacts })) }, + { task: { title: task.title, spec: task.spec }, acceptance: task.acceptance, submissions: await Promise.all(task.submissions.map(async (item) => ({ submission_id: item.id, summary: item.summary, artifacts: await readArtifacts(item.artifacts) }))) }, ) const winner = Array.isArray(verdict.submissions) ? verdict.submissions.find((item) => item && item.submission_id === verdict.winner_submission_id) : undefined const checks = winner === undefined ? null : validChecks(winner.checks, task.acceptance) @@ -193,7 +211,7 @@ async function publishIfNeeded(home) { const plan = await askModel( `You run the task board of a guild. Using the guild brief, propose up to ${slots} new, clearly scoped tasks that are not duplicates of the existing ones. ` + `Each reward is a whole number of points between 1 and ${rewardCap}; the total must not exceed ${home.wallet.available}. ` - + 'A task must be self-contained: include any source text the taker needs inside the spec, or say exactly where to get it. Acceptance items must be checkable from the submission alone. ' + + 'Takers deliver by uploading files to the platform, so never require a link or external hosting in the acceptance items. A task must be self-contained: include any source text the taker needs inside the spec, or say exactly where to get it. Acceptance items must be checkable from the submission alone. ' + 'Reply as {"tasks":[{"title":"...","spec":"...","template":"piecework"|"contest","reward":40,"deadline_hours":48,"acceptance":["...","..."]}]} with 1 to 5 acceptance items per task.', { brief: config.brief, existing_tasks: mine.map((task) => task.title), weekly_goal: guild.weekly_goal, weekly_progress: guild.weekly_progress }, ) diff --git a/tools/voko-company-agent/brief.example.md b/tools/voko-company-agent/brief.example.md index 91c1465..0cd0aad 100644 --- a/tools/voko-company-agent/brief.example.md +++ b/tools/voko-company-agent/brief.example.md @@ -9,5 +9,5 @@ This week we want: Guidelines for tasks: - one clear deliverable per task, finishable by one agent within two days -- the deliverable is a link to a document; say so in the acceptance checklist +- the deliverable is a file uploaded to the platform (for example a Markdown document) - typical rewards: 20–60 points; never above 100