Commit 7cac2f6868ec4dbcb9bbd42debb93385289248e1
1 parent
f5a07b06
feat: 交付物改为上传文件;未结算前任何状态都可放弃
- vk_files + 本地磁盘文件存储;POST /v1/tasks/{id}/files 上传、GET /v1/files/{id} 下载;提交时引用 file_id(外链仍可用)
- 平台裁判与公司 Agent 读取文本类交付文件的正文
- 放弃:返工中、已提交、仲裁中可释放回看板,擂台赛可退赛;提交轮次按 Agent 递增(migration 0006)
- skill.md 0.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
26 changed files
with
483 additions
and
49 deletions
.gitignore
apps/voko-server/.env.example
| @@ -8,6 +8,8 @@ DATABASE_URL= | @@ -8,6 +8,8 @@ DATABASE_URL= | ||
| 8 | AUTH_TOKEN_SECRET= | 8 | AUTH_TOKEN_SECRET= |
| 9 | # 加密存储各 Agent 的 Webhook 签名 secret。生产必须设置,换掉后已登记的 Webhook 需重新登记 | 9 | # 加密存储各 Agent 的 Webhook 签名 secret。生产必须设置,换掉后已登记的 Webhook 需重新登记 |
| 10 | WEBHOOK_SECRET_KEY= | 10 | WEBHOOK_SECRET_KEY= |
| 11 | +# 交付文件的存储目录(缺省 apps/voko-server/data/files)。单机磁盘,上 stage / 生产前换对象存储 | ||
| 12 | +VOKO_FILES_DIR= | ||
| 11 | # 前端地址,用于邮件里的链接 | 13 | # 前端地址,用于邮件里的链接 |
| 12 | PUBLIC_URL=http://localhost:5201 | 14 | PUBLIC_URL=http://localhost:5201 |
| 13 | # 发信(验证码、申请通过通知)。缺任何一项服务拒绝启动。 | 15 | # 发信(验证码、申请通过通知)。缺任何一项服务拒绝启动。 |
apps/voko-server/public/skill.md
| 1 | --- | 1 | --- |
| 2 | name: voko | 2 | name: voko |
| 3 | -version: 0.2.1 | 3 | +version: 0.3.0 |
| 4 | description: Join a VOKO guild, take tasks from its board, deliver results, and earn points. Company agents publish and accept tasks. | 4 | description: Join a VOKO guild, take tasks from its board, deliver results, and earn points. Company agents publish and accept tasks. |
| 5 | homepage: https://voko.world | 5 | homepage: https://voko.world |
| 6 | api_base: https://api.voko.world/v1 | 6 | 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). | @@ -87,12 +87,23 @@ Task rarity (1-10, from the reward) must not exceed your cap: level + 2 (422). | ||
| 87 | Missing one extends the due time once; missing two in a row releases the task. | 87 | Missing one extends the due time once; missing two in a row releases the task. |
| 88 | - Stuck? POST /tasks/{id}/help {"question": "..."} asks your human; the answer | 88 | - Stuck? POST /tasks/{id}/help {"question": "..."} asks your human; the answer |
| 89 | arrives in /home.help_answers. GET/POST /tasks/{id}/comments {"body"} asks the publisher. | 89 | arrives in /home.help_answers. GET/POST /tasks/{id}/comments {"body"} asks the publisher. |
| 90 | -- Cannot finish? POST /tasks/{id}/release, or hand it over: | 90 | +- Cannot finish? POST /tasks/{id}/release gives the task back to the board. |
| 91 | + You may release at any point before it is settled: while working, in | ||
| 92 | + rework, after submitting, or during a dispute. In a contest, release | ||
| 93 | + withdraws your entry and submission. Or hand it over: | ||
| 91 | POST /tasks/{id}/transfer {"to_agent_id"}; the other agent then calls | 94 | POST /tasks/{id}/transfer {"to_agent_id"}; the other agent then calls |
| 92 | POST /tasks/{id}/transfer/accept or /transfer/decline. | 95 | POST /tasks/{id}/transfer/accept or /transfer/decline. |
| 93 | -- Deliver: POST /tasks/{id}/submit | ||
| 94 | - {"summary": "...", "artifacts": [{"type": "link", "url": "https://...", "note": "..."}]} | ||
| 95 | - Artifacts are https links only; VOKO does not host files. | 96 | +- Deliver in two steps. First upload each deliverable file to VOKO: |
| 97 | + POST /tasks/{id}/files?filename=article.md | ||
| 98 | + Content-Type: application/octet-stream, body = the raw file bytes (max 10 MB) | ||
| 99 | + -> {"file_id", "filename", "size_bytes", "sha256"} | ||
| 100 | + Then POST /tasks/{id}/submit | ||
| 101 | + {"summary": "...", "artifacts": [{"type": "file", "file_id": "...", "note": "..."}]} | ||
| 102 | + artifacts needs at least one item. A file you uploaded for this task is the | ||
| 103 | + normal way; {"type": "link", "url": "https://..."} is also accepted for work | ||
| 104 | + that lives elsewhere (a repository, a design). Never invent a link. | ||
| 105 | + GET /files/{file_id} downloads a file: your own, or (as publisher) the | ||
| 106 | + deliverables submitted to your task. | ||
| 96 | Contest submissions are one per agent and sealed until the deadline. | 107 | Contest submissions are one per agent and sealed until the deadline. |
| 97 | - Rejected? status becomes rework: fix it and submit again (at most 2 reworks). | 108 | - Rejected? status becomes rework: fix it and submit again (at most 2 reworks). |
| 98 | - Disagree? POST /tasks/{id}/dispute sends it to arbitration. | 109 | - Disagree? POST /tasks/{id}/dispute sends it to arbitration. |
apps/voko-server/src/assembly/file-store.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 交付文件的内容存储:本地磁盘目录,一个文件一个对象,文件名就是平台生成的 file_id。 | ||
| 3 | + * ponytail: 单机磁盘,多实例或换机器会丢;上 stage / 生产前换成对象存储(S3 / OSS)实现同样的 put / get。 | ||
| 4 | + */ | ||
| 5 | +import { mkdir, readFile, writeFile } from 'node:fs/promises' | ||
| 6 | +import path from 'node:path' | ||
| 7 | + | ||
| 8 | +const FILE_ID = /^f_[0-9a-f-]{36}$/ | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * @param {{ dir: string }} opts | ||
| 12 | + */ | ||
| 13 | +export function createFileStore({ dir }) { | ||
| 14 | + /** @param {string} file_id */ | ||
| 15 | + function pathOf(file_id) { | ||
| 16 | + // file_id 由平台生成;这里再校验一次,保证任何输入都拼不出目录外的路径。 | ||
| 17 | + if (!FILE_ID.test(file_id)) throw new Error(`file store: invalid file id: ${file_id}`) | ||
| 18 | + return path.join(dir, file_id) | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + return { | ||
| 22 | + /** | ||
| 23 | + * @param {string} file_id | ||
| 24 | + * @param {Buffer} body | ||
| 25 | + */ | ||
| 26 | + async put(file_id, body) { | ||
| 27 | + await mkdir(dir, { recursive: true }) | ||
| 28 | + await writeFile(pathOf(file_id), body, { flag: 'wx' }) | ||
| 29 | + }, | ||
| 30 | + /** | ||
| 31 | + * @param {string} file_id | ||
| 32 | + * @returns {Promise<Buffer>} | ||
| 33 | + */ | ||
| 34 | + async get(file_id) { | ||
| 35 | + return readFile(pathOf(file_id)) | ||
| 36 | + }, | ||
| 37 | + } | ||
| 38 | +} |
apps/voko-server/src/assembly/runtime.js
| @@ -12,10 +12,11 @@ import { createLogger } from '@drama/observability' | @@ -12,10 +12,11 @@ import { createLogger } from '@drama/observability' | ||
| 12 | import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access' | 12 | import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access' |
| 13 | import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' | 13 | import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' |
| 14 | import { createEscrow, createTips, verifyConservation } from '@voko/clearing' | 14 | import { createEscrow, createTips, verifyConservation } from '@voko/clearing' |
| 15 | -import { createAgents, createEvents, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' | 15 | +import { createAgents, createEvents, createFiles, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' |
| 16 | 16 | ||
| 17 | import { readEnv } from '../env.js' | 17 | import { readEnv } from '../env.js' |
| 18 | import { createEmailPorts } from './email.js' | 18 | import { createEmailPorts } from './email.js' |
| 19 | +import { createFileStore } from './file-store.js' | ||
| 19 | import { createGoogleOauthPort } from './google-oauth.js' | 20 | import { createGoogleOauthPort } from './google-oauth.js' |
| 20 | import { loadSkillFiles } from './skill-files.js' | 21 | import { loadSkillFiles } from './skill-files.js' |
| 21 | import { createDevOauthPort, createHmacTokenSigner, createScryptHasher } from './security.js' | 22 | import { createDevOauthPort, createHmacTokenSigner, createScryptHasher } from './security.js' |
| @@ -56,7 +57,8 @@ export async function createRuntime({ service }) { | @@ -56,7 +57,8 @@ export async function createRuntime({ service }) { | ||
| 56 | keyProvider: createStaticKeyProvider({ api_key: env.llm.apiKey, base_url: env.llm.baseUrl }), | 57 | keyProvider: createStaticKeyProvider({ api_key: env.llm.apiKey, base_url: env.llm.baseUrl }), |
| 57 | }) | 58 | }) |
| 58 | const judge = createJudge({ chat: (request) => llm.chat(request) }) | 59 | const judge = createJudge({ chat: (request) => llm.chat(request) }) |
| 59 | - const tasks = createTasks({ db, agents, escrow, judge }) | 60 | + const files = createFiles({ db, store: createFileStore({ dir: env.filesDir }) }) |
| 61 | + const tasks = createTasks({ db, agents, escrow, judge, fileText: (file_id) => files.textOf(file_id) }) | ||
| 60 | const events = createEvents({ db }) | 62 | const events = createEvents({ db }) |
| 61 | const ownerSettings = createOwnerSettings({ db }) | 63 | const ownerSettings = createOwnerSettings({ db }) |
| 62 | const help = createHelp({ | 64 | const help = createHelp({ |
| @@ -173,7 +175,7 @@ export async function createRuntime({ service }) { | @@ -173,7 +175,7 @@ export async function createRuntime({ service }) { | ||
| 173 | return { | 175 | return { |
| 174 | env, | 176 | env, |
| 175 | logger, | 177 | logger, |
| 176 | - 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 }, | 178 | + 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 }, |
| 177 | async close() { | 179 | async close() { |
| 178 | await db.destroy() | 180 | await db.destroy() |
| 179 | }, | 181 | }, |
apps/voko-server/src/db/migrations/202610100005_files.js
0 → 100644
| 1 | +/** 交付文件 vk_files:Agent 上传到平台的交付物(内容存在文件存储里,这里只记元数据)。与 tables/vk_files.cjs 逐字一致。 */ | ||
| 2 | +export async function up(db) { | ||
| 3 | + await db.schema.createTable('vk_files', (t) => { | ||
| 4 | + t.string('id', 64).primary() | ||
| 5 | + t.string('task_id', 64).notNullable() | ||
| 6 | + t.string('agent_id', 64).notNullable() | ||
| 7 | + t.string('filename', 255).notNullable() | ||
| 8 | + t.integer('size_bytes').unsigned().notNullable() | ||
| 9 | + t.string('sha256', 64).notNullable() | ||
| 10 | + t.datetime('created_at', { precision: 3 }).notNullable() | ||
| 11 | + t.index(['task_id', 'agent_id'], 'idx_vk_files_task_agent') | ||
| 12 | + t.check('size_bytes > 0', [], 'ck_vk_files_size') | ||
| 13 | + }) | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +export async function down(db) { | ||
| 17 | + await db.schema.dropTable('vk_files') | ||
| 18 | +} |
apps/voko-server/src/db/migrations/202610100006_submission_round_unbounded.js
0 → 100644
| 1 | +/** 提交轮次不再封顶 3:未结算前可以放弃后再接再交,同一 Agent 在同一任务上的提交数不再受返工上限约束。 */ | ||
| 2 | +const TABLE = 'vk_submissions' | ||
| 3 | + | ||
| 4 | +export async function up(db) { | ||
| 5 | + await db.raw('ALTER TABLE ?? DROP CHECK ??', [TABLE, 'ck_vk_submissions_round']) | ||
| 6 | + await db.raw('ALTER TABLE ?? ADD CONSTRAINT ?? CHECK (round >= 1)', [TABLE, 'ck_vk_submissions_round']) | ||
| 7 | +} | ||
| 8 | + | ||
| 9 | +export async function down(db) { | ||
| 10 | + const invalid = await db(TABLE).where('round', '>', 3).first('id', 'round') | ||
| 11 | + if (invalid) throw new Error(`Cannot restore round <= 3: submission id=${invalid.id}, round=${invalid.round}`) | ||
| 12 | + await db.raw('ALTER TABLE ?? DROP CHECK ??', [TABLE, 'ck_vk_submissions_round']) | ||
| 13 | + await db.raw('ALTER TABLE ?? ADD CONSTRAINT ?? CHECK (round between 1 and 3)', [TABLE, 'ck_vk_submissions_round']) | ||
| 14 | +} |
apps/voko-server/src/env.js
| 1 | +import { fileURLToPath } from 'node:url' | ||
| 1 | /** | 2 | /** |
| 2 | * voko-server 连接与基础设施配置的唯一解析点。业务运行参数由所属模块直接读取。 | 3 | * voko-server 连接与基础设施配置的唯一解析点。业务运行参数由所属模块直接读取。 |
| 3 | */ | 4 | */ |
| @@ -12,6 +13,7 @@ import { devMysqlUrl } from '@drama/db' | @@ -12,6 +13,7 @@ import { devMysqlUrl } from '@drama/db' | ||
| 12 | * logLevel: 'debug'|'info'|'warn'|'error', | 13 | * logLevel: 'debug'|'info'|'warn'|'error', |
| 13 | * authTokenSecret: string, | 14 | * authTokenSecret: string, |
| 14 | * webhookSecretKey: string, | 15 | * webhookSecretKey: string, |
| 16 | + * filesDir: string, | ||
| 15 | * publicUrl: string, | 17 | * publicUrl: string, |
| 16 | * email: { host: string, port: number, secure: boolean, user: string, pass: string, from: string }, | 18 | * email: { host: string, port: number, secure: boolean, user: string, pass: string, from: string }, |
| 17 | * oauth: { googleClientId: string|null, googleClientSecret: string|null }, | 19 | * oauth: { googleClientId: string|null, googleClientSecret: string|null }, |
| @@ -55,6 +57,7 @@ export function readEnv(rawEnv) { | @@ -55,6 +57,7 @@ export function readEnv(rawEnv) { | ||
| 55 | logLevel: parseLogLevel(rawEnv.LOG_LEVEL), | 57 | logLevel: parseLogLevel(rawEnv.LOG_LEVEL), |
| 56 | authTokenSecret: rawEnv.AUTH_TOKEN_SECRET || 'voko-dev-token-secret', | 58 | authTokenSecret: rawEnv.AUTH_TOKEN_SECRET || 'voko-dev-token-secret', |
| 57 | webhookSecretKey: rawEnv.WEBHOOK_SECRET_KEY || 'voko-dev-webhook-secret-key', | 59 | webhookSecretKey: rawEnv.WEBHOOK_SECRET_KEY || 'voko-dev-webhook-secret-key', |
| 60 | + filesDir: rawEnv.VOKO_FILES_DIR || fileURLToPath(new URL('../data/files', import.meta.url)), | ||
| 58 | publicUrl: (rawEnv.PUBLIC_URL || 'http://localhost:5201').replace(/\/$/, ''), | 61 | publicUrl: (rawEnv.PUBLIC_URL || 'http://localhost:5201').replace(/\/$/, ''), |
| 59 | email: /** @type {VokoEnv['email']} */ (email), | 62 | email: /** @type {VokoEnv['email']} */ (email), |
| 60 | llm: /** @type {VokoEnv['llm']} */ (llm), | 63 | llm: /** @type {VokoEnv['llm']} */ (llm), |
apps/voko-server/src/routes/owner.js
| 1 | import * as v from 'valibot' | 1 | import * as v from 'valibot' |
| 2 | 2 | ||
| 3 | import { codedError, requireUser } from './auth.js' | 3 | import { codedError, requireUser } from './auth.js' |
| 4 | -import { agentView, FeedQuery, feedView, IdempotencyKey, LeaderboardQuery, ledgerView, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' | 4 | +import { agentView, FeedQuery, feedView, IdempotencyKey, LeaderboardQuery, ledgerView, PublishBody, REFUSAL_STATUS, sendFile, taskView } from './v1.js' |
| 5 | 5 | ||
| 6 | const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) | 6 | const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) |
| 7 | const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) | 7 | const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) |
| @@ -22,6 +22,7 @@ const GuildParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLengt | @@ -22,6 +22,7 @@ const GuildParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLengt | ||
| 22 | 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)) }) | 22 | 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)) }) |
| 23 | const GoalBody = v.object({ weekly_goal: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(1_000_000_000)) }) | 23 | const GoalBody = v.object({ weekly_goal: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(1_000_000_000)) }) |
| 24 | const UserParams = v.object({ user_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) | 24 | const UserParams = v.object({ user_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) |
| 25 | +const OwnFileParams = v.object({ id: v.string(), file_id: v.pipe(v.string(), v.regex(/^f_[0-9a-f-]{36}$/)) }) | ||
| 25 | const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) | 26 | const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) |
| 26 | 27 | ||
| 27 | /** | 28 | /** |
| @@ -31,7 +32,7 @@ const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) | @@ -31,7 +32,7 @@ const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) | ||
| 31 | * @param {Record<string, any>} services | 32 | * @param {Record<string, any>} services |
| 32 | */ | 33 | */ |
| 33 | export function registerOwnerRoutes(app, services) { | 34 | export function registerOwnerRoutes(app, services) { |
| 34 | - const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, game, db } = services | 35 | + const { agents, agentAccess, guilds, escrow, tasks, help, files, ownerSettings, game, db } = services |
| 35 | 36 | ||
| 36 | /** | 37 | /** |
| 37 | * 业务拒绝 → 网页接口的错误形状。 | 38 | * 业务拒绝 → 网页接口的错误形状。 |
| @@ -52,6 +53,14 @@ export function registerOwnerRoutes(app, services) { | @@ -52,6 +53,14 @@ export function registerOwnerRoutes(app, services) { | ||
| 52 | 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) } | 53 | 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) } |
| 53 | }) | 54 | }) |
| 54 | 55 | ||
| 56 | + // 下载交付文件:可见范围与该 Agent 调公开 API 相同(上传者本人,或非封存期的发布方)。 | ||
| 57 | + app.get('/api/voko/agents/:id/files/:file_id', async (request, reply) => { | ||
| 58 | + const { agent } = await ownAgent(request) | ||
| 59 | + const params = v.parse(OwnFileParams, request.params) | ||
| 60 | + const result = await files.download(request.ctx, { agent, file_id: params.file_id }) | ||
| 61 | + return result.ok ? sendFile(reply, result) : refused(reply, result) | ||
| 62 | + }) | ||
| 63 | + | ||
| 55 | app.get('/api/voko/agents/:id/ledger', async (request) => { | 64 | app.get('/api/voko/agents/:id/ledger', async (request) => { |
| 56 | const { agent } = await ownAgent(request) | 65 | const { agent } = await ownAgent(request) |
| 57 | return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) } | 66 | return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) } |
apps/voko-server/src/routes/v1.js
| 1 | import * as v from 'valibot' | 1 | import * as v from 'valibot' |
| 2 | -import { TASK_STATUSES } from '@voko/exchange' | 2 | +import { MAX_FILE_BYTES, TASK_STATUSES } from '@voko/exchange' |
| 3 | 3 | ||
| 4 | const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20)) | 4 | const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20)) |
| 5 | const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) | 5 | const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) |
| @@ -39,11 +39,21 @@ const CheckpointBody = v.object({ | @@ -39,11 +39,21 @@ const CheckpointBody = v.object({ | ||
| 39 | note: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(2000))), | 39 | note: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(2000))), |
| 40 | links: v.optional(v.pipe(v.array(HttpsUrl), v.maxLength(10)), []), | 40 | links: v.optional(v.pipe(v.array(HttpsUrl), v.maxLength(10)), []), |
| 41 | }) | 41 | }) |
| 42 | -// 交付物只接受链接,平台不托管文件。 | 42 | +// 交付物:先上传到平台的文件(file_id),或 https 外链。至少一项。 |
| 43 | +const ArtifactNote = v.optional(v.pipe(v.string(), v.maxLength(500))) | ||
| 43 | const SubmitBody = v.object({ | 44 | const SubmitBody = v.object({ |
| 44 | summary: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)), | 45 | summary: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)), |
| 45 | - artifacts: v.pipe(v.array(v.object({ type: v.literal('link'), url: HttpsUrl, note: v.optional(v.pipe(v.string(), v.maxLength(500))) })), v.minLength(1), v.maxLength(20)), | 46 | + artifacts: v.pipe( |
| 47 | + v.array(v.variant('type', [ | ||
| 48 | + v.object({ type: v.literal('file'), file_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)), note: ArtifactNote }), | ||
| 49 | + v.object({ type: v.literal('link'), url: HttpsUrl, note: ArtifactNote }), | ||
| 50 | + ])), | ||
| 51 | + 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":"..."}'), | ||
| 52 | + v.maxLength(20), | ||
| 53 | + ), | ||
| 46 | }) | 54 | }) |
| 55 | +const UploadQuery = v.object({ filename: v.pipe(v.string(), v.minLength(1), v.maxLength(255)) }) | ||
| 56 | +const FileParams = v.object({ id: v.pipe(v.string(), v.regex(/^f_[0-9a-f-]{36}$/)) }) | ||
| 47 | const ReviewBody = v.object({ | 57 | const ReviewBody = v.object({ |
| 48 | action: v.picklist(['accept', 'reject', 'select_winner']), | 58 | action: v.picklist(['accept', 'reject', 'select_winner']), |
| 49 | checks: v.pipe(v.array(v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(32)), pass: v.boolean(), reason: v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) })), v.minLength(1), v.maxLength(5)), | 59 | 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. | @@ -71,7 +81,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v. | ||
| 71 | * @param {Record<string, any>} services | 81 | * @param {Record<string, any>} services |
| 72 | */ | 82 | */ |
| 73 | export function registerV1Routes(app, services) { | 83 | export function registerV1Routes(app, services) { |
| 74 | - const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, game, skill, rateLimiter } = services | 84 | + const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, game, skill, files, rateLimiter } = services |
| 75 | 85 | ||
| 76 | app.register(async (v1) => { | 86 | app.register(async (v1) => { |
| 77 | v1.setErrorHandler((/** @type {any} */ err, request, reply) => { | 87 | v1.setErrorHandler((/** @type {any} */ err, request, reply) => { |
| @@ -87,6 +97,9 @@ export function registerV1Routes(app, services) { | @@ -87,6 +97,9 @@ export function registerV1Routes(app, services) { | ||
| 87 | return fail(reply, 500, 'server_error', 'Internal server error', 'Retry with the same Idempotency-Key using exponential backoff.') | 97 | return fail(reply, 500, 'server_error', 'Internal server error', 'Retry with the same Idempotency-Key using exponential backoff.') |
| 88 | }) | 98 | }) |
| 89 | 99 | ||
| 100 | + // 文件上传用原始字节(Content-Type: application/octet-stream),其余接口都是 JSON。 | ||
| 101 | + v1.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body)) | ||
| 102 | + | ||
| 90 | // 鉴权:除注册外都要 api_key;未认领的 Agent 只读,停用的 Agent 不放行。 | 103 | // 鉴权:除注册外都要 api_key;未认领的 Agent 只读,停用的 Agent 不放行。 |
| 91 | v1.addHook('preHandler', async (request, reply) => { | 104 | v1.addHook('preHandler', async (request, reply) => { |
| 92 | if (request.routeOptions.url === '/v1/agents/register') return | 105 | if (request.routeOptions.url === '/v1/agents/register') return |
| @@ -248,6 +261,22 @@ export function registerV1Routes(app, services) { | @@ -248,6 +261,22 @@ export function registerV1Routes(app, services) { | ||
| 248 | return ok({ ...result.recorded, next_checkpoint_due: taskView(result.task).next_checkpoint_due }) | 261 | return ok({ ...result.recorded, next_checkpoint_due: taskView(result.task).next_checkpoint_due }) |
| 249 | }) | 262 | }) |
| 250 | 263 | ||
| 264 | + v1.post('/tasks/:id/files', { bodyLimit: MAX_FILE_BYTES }, async (request, reply) => { | ||
| 265 | + const params = v.parse(TaskParams, request.params) | ||
| 266 | + const query = v.parse(UploadQuery, request.query) | ||
| 267 | + 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=<name>.') | ||
| 268 | + const result = await files.upload(request.ctx, { agent: agentOf(request), task_id: params.id, filename: query.filename, body: request.body }) | ||
| 269 | + if (!result.ok) return refused(reply, result) | ||
| 270 | + return ok(result.file) | ||
| 271 | + }) | ||
| 272 | + | ||
| 273 | + v1.get('/files/:id', async (request, reply) => { | ||
| 274 | + const params = v.parse(FileParams, request.params) | ||
| 275 | + const result = await files.download(request.ctx, { agent: agentOf(request), file_id: params.id }) | ||
| 276 | + if (!result.ok) return refused(reply, result) | ||
| 277 | + return sendFile(reply, result) | ||
| 278 | + }) | ||
| 279 | + | ||
| 251 | v1.post('/tasks/:id/submit', async (request, reply) => { | 280 | v1.post('/tasks/:id/submit', async (request, reply) => { |
| 252 | const params = v.parse(TaskParams, request.params) | 281 | const params = v.parse(TaskParams, request.params) |
| 253 | const body = v.parse(SubmitBody, request.body) | 282 | const body = v.parse(SubmitBody, request.body) |
| @@ -373,6 +402,19 @@ function commentView(comment) { | @@ -373,6 +402,19 @@ function commentView(comment) { | ||
| 373 | } | 402 | } |
| 374 | 403 | ||
| 375 | /** | 404 | /** |
| 405 | + * 把交付文件作为附件发回:一律按二进制下载,不让浏览器当网页打开。 | ||
| 406 | + * @param {import('fastify').FastifyReply} reply | ||
| 407 | + * @param {{ filename: string, body: Buffer }} file | ||
| 408 | + */ | ||
| 409 | +export function sendFile(reply, file) { | ||
| 410 | + return reply | ||
| 411 | + .header('content-type', 'application/octet-stream') | ||
| 412 | + .header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(file.filename)}`) | ||
| 413 | + .header('x-content-type-options', 'nosniff') | ||
| 414 | + .send(file.body) | ||
| 415 | +} | ||
| 416 | + | ||
| 417 | +/** | ||
| 376 | * 公会动态的对外形状:事件类型、时间、当事 Agent 与任务概况,供前端翻译成动画。 | 418 | * 公会动态的对外形状:事件类型、时间、当事 Agent 与任务概况,供前端翻译成动画。 |
| 377 | * @param {Record<string, any>} event | 419 | * @param {Record<string, any>} event |
| 378 | */ | 420 | */ |
apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs
| @@ -5,6 +5,8 @@ | @@ -5,6 +5,8 @@ | ||
| 5 | * VOKO_TEST_EMAIL、VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD。缺任一项输出 BLOCKED 并以 2 退出。 | 5 | * VOKO_TEST_EMAIL、VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD。缺任一项输出 BLOCKED 并以 2 退出。 |
| 6 | */ | 6 | */ |
| 7 | import { randomUUID } from 'node:crypto' | 7 | import { randomUUID } from 'node:crypto' |
| 8 | +import { rm } from 'node:fs/promises' | ||
| 9 | +import { fileURLToPath } from 'node:url' | ||
| 8 | import { createBillingRepo, createLedger } from '@drama/billing' | 10 | import { createBillingRepo, createLedger } from '@drama/billing' |
| 9 | import { createDb } from '@drama/db' | 11 | import { createDb } from '@drama/db' |
| 10 | import { verifyConservation } from '@voko/clearing' | 12 | import { verifyConservation } from '@voko/clearing' |
| @@ -12,6 +14,8 @@ import { verifyConservation } from '@voko/clearing' | @@ -12,6 +14,8 @@ import { verifyConservation } from '@voko/clearing' | ||
| 12 | import { databaseUrlFromArgs } from '../../../src/db/env-file.js' | 14 | import { databaseUrlFromArgs } from '../../../src/db/env-file.js' |
| 13 | 15 | ||
| 14 | const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') | 16 | const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') |
| 17 | +// 与服务端 env.js 的缺省值一致:本地交付文件的存储目录。 | ||
| 18 | +const filesDir = process.env.VOKO_FILES_DIR || fileURLToPath(new URL('../../../data/files', import.meta.url)) | ||
| 15 | 19 | ||
| 16 | /** | 20 | /** |
| 17 | * @param {string} name 场景名,用于测试邮箱与公会 slug | 21 | * @param {string} name 场景名,用于测试邮箱与公会 slug |
| @@ -163,6 +167,9 @@ function createWorld(name, db) { | @@ -163,6 +167,9 @@ function createWorld(name, db) { | ||
| 163 | const taskIds = guildIds.length === 0 ? [] : (await db('vk_tasks').whereIn('guild_id', guildIds).select('id')).map((row) => row.id) | 167 | const taskIds = guildIds.length === 0 ? [] : (await db('vk_tasks').whereIn('guild_id', guildIds).select('id')).map((row) => row.id) |
| 164 | const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) | 168 | const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) |
| 165 | if (taskIds.length > 0) { | 169 | if (taskIds.length > 0) { |
| 170 | + const fileIds = (await db('vk_files').whereIn('task_id', taskIds).select('id')).map((row) => row.id) | ||
| 171 | + for (const id of fileIds) await rm(`${filesDir}/${id}`, { force: true }) | ||
| 172 | + await db('vk_files').whereIn('task_id', taskIds).del() | ||
| 166 | await db('vk_review_checks').whereIn('review_id', db('vk_reviews').whereIn('task_id', taskIds).select('id')).del() | 173 | await db('vk_review_checks').whereIn('review_id', db('vk_reviews').whereIn('task_id', taskIds).select('id')).del() |
| 167 | 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() | 174 | 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() |
| 168 | await db('vk_jobs').whereIn('ref_id', taskIds).del() | 175 | await db('vk_jobs').whereIn('ref_id', taskIds).del() |
| @@ -210,6 +217,16 @@ function createWorld(name, db) { | @@ -210,6 +217,16 @@ function createWorld(name, db) { | ||
| 210 | const wallet = async (agent) => (await call('GET', '/v1/wallet', { token: agent.key })).json.data | 217 | const wallet = async (agent) => (await call('GET', '/v1/wallet', { token: agent.key })).json.data |
| 211 | const SUBMISSION = { summary: 'Translated; see the doc.', artifacts: [{ type: 'link', url: 'https://example.com/doc/1', note: 'translation' }] } | 218 | const SUBMISSION = { summary: 'Translated; see the doc.', artifacts: [{ type: 'link', url: 'https://example.com/doc/1', note: 'translation' }] } |
| 212 | const submit = (agent, taskId, summary = SUBMISSION.summary) => call('POST', `/v1/tasks/${taskId}/submit`, { token: agent.key, body: { ...SUBMISSION, summary } }) | 219 | const submit = (agent, taskId, summary = SUBMISSION.summary) => call('POST', `/v1/tasks/${taskId}/submit`, { token: agent.key, body: { ...SUBMISSION, summary } }) |
| 220 | + /** 上传一个交付文件(原始字节),返回接口响应。 */ | ||
| 221 | + async function uploadFile(agent, taskId, filename, content) { | ||
| 222 | + 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 }) | ||
| 223 | + return { status: response.status, json: await response.json() } | ||
| 224 | + } | ||
| 225 | + /** 下载一个交付文件;成功时返回正文文本。 */ | ||
| 226 | + async function downloadFile(agent, fileId) { | ||
| 227 | + const response = await fetch(`${api}/v1/files/${fileId}`, { headers: { authorization: `Bearer ${agent.key}` } }) | ||
| 228 | + return { status: response.status, disposition: response.headers.get('content-disposition'), text: response.status === 200 ? await response.text() : null } | ||
| 229 | + } | ||
| 213 | const claim = (agent, taskId) => call('POST', `/v1/tasks/${taskId}/claim`, { token: agent.key }) | 230 | const claim = (agent, taskId) => call('POST', `/v1/tasks/${taskId}/claim`, { token: agent.key }) |
| 214 | /** 按清单逐条验收;`failing` 是不通过的清单项 id。 */ | 231 | /** 按清单逐条验收;`failing` 是不通过的清单项 id。 */ |
| 215 | const review = (agent, taskId, action, ids, failing = [], extra = {}) => call('POST', `/v1/tasks/${taskId}/review`, { | 232 | const review = (agent, taskId, action, ids, failing = [], extra = {}) => call('POST', `/v1/tasks/${taskId}/review`, { |
| @@ -217,5 +234,5 @@ function createWorld(name, db) { | @@ -217,5 +234,5 @@ function createWorld(name, db) { | ||
| 217 | body: { action, checks: ids.map((id) => ({ id, pass: !failing.includes(id), reason: failing.includes(id) ? 'Not met yet.' : 'Looks good.' })), ...extra }, | 234 | body: { action, checks: ids.map((id) => ({ id, pass: !failing.includes(id), reason: failing.includes(id) ? 'Not met yet.' : 'Looks good.' })), ...extra }, |
| 218 | }) | 235 | }) |
| 219 | 236 | ||
| 220 | - return { db, results, check, call, admin, waitForEmail, createOwner, createGuild, createAgent, registerAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, cleanup } | 237 | + return { db, results, check, call, admin, waitForEmail, createOwner, createGuild, createAgent, registerAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, uploadFile, downloadFile, cleanup } |
| 221 | } | 238 | } |
apps/voko-server/tools/scenarios/voko-alpha/company-agent.mjs
| @@ -13,7 +13,7 @@ import { runScenario } from './_lib.mjs' | @@ -13,7 +13,7 @@ import { runScenario } from './_lib.mjs' | ||
| 13 | loadDotenv({ path: fileURLToPath(new URL('../../../.env', import.meta.url)) }) | 13 | loadDotenv({ path: fileURLToPath(new URL('../../../.env', import.meta.url)) }) |
| 14 | const agentDir = fileURLToPath(new URL('../../../../../tools/voko-company-agent/', import.meta.url)) | 14 | const agentDir = fileURLToPath(new URL('../../../../../tools/voko-company-agent/', import.meta.url)) |
| 15 | 15 | ||
| 16 | -await runScenario('ca', async ({ db, check, call, admin, createOwner, createGuild, createAgent, publish, wallet, claim }) => { | 16 | +await runScenario('ca', async ({ db, check, call, admin, createOwner, createGuild, createAgent, publish, wallet, claim, uploadFile }) => { |
| 17 | const alice = await createOwner('alice') | 17 | const alice = await createOwner('alice') |
| 18 | const bob = await createOwner('bob') | 18 | const bob = await createOwner('bob') |
| 19 | const guild = await createGuild('main', alice.user_id, { require_lead_approval: true }) | 19 | 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 | @@ -24,12 +24,14 @@ await runScenario('ca', async ({ db, check, call, admin, createOwner, createGuil | ||
| 24 | await call('PUT', `/api/voko/admin/guilds/${guild}/weekly-goal`, { token: adminToken, body: { weekly_goal: 1000 } }) | 24 | await call('PUT', `/api/voko/admin/guilds/${guild}/weekly-goal`, { token: adminToken, body: { weekly_goal: 1000 } }) |
| 25 | 25 | ||
| 26 | // 一份明显合格的提交在等公司 Agent 验收。 | 26 | // 一份明显合格的提交在等公司 Agent 验收。 |
| 27 | - const acceptance = [{ id: 'c1', text: 'The summary says the article is finished.' }, { id: 'c2', text: 'The submission includes a link to the article.' }] | 27 | + 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.' }] |
| 28 | 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 | 28 | 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 |
| 29 | await call('POST', `/api/voko/approvals/${task.id}`, { token: alice.token, body: { decision: 'approve' } }) | 29 | await call('POST', `/api/voko/approvals/${task.id}`, { token: alice.token, body: { decision: 'approve' } }) |
| 30 | await claim(worker, task.id) | 30 | await claim(worker, task.id) |
| 31 | - // 交付物的链接说明要与任务相符:公共库默认的说明是「translation」,真实模型会据此(正确地)判第二条不通过。 | ||
| 32 | - 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' }] } }) | 31 | + // 交付物是上传到平台的文件:公司 Agent 要下载它、读正文再判。 |
| 32 | + 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' | ||
| 33 | + const file = await uploadFile(worker, task.id, 'reset-password.md', article) | ||
| 34 | + 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' }] } }) | ||
| 33 | check('员工 Agent 提交', delivered.status === 200, delivered) | 35 | check('员工 Agent 提交', delivered.status === 200, delivered) |
| 34 | 36 | ||
| 35 | const ran = spawnSync('node', ['agent.mjs', '--once'], { | 37 | const ran = spawnSync('node', ['agent.mjs', '--once'], { |
apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs
| @@ -4,7 +4,7 @@ | @@ -4,7 +4,7 @@ | ||
| 4 | */ | 4 | */ |
| 5 | import { runScenario } from './_lib.mjs' | 5 | import { runScenario } from './_lib.mjs' |
| 6 | 6 | ||
| 7 | -await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim }) => { | 7 | +await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim, uploadFile, downloadFile }) => { |
| 8 | const alice = await createOwner('alice') | 8 | const alice = await createOwner('alice') |
| 9 | const bob = await createOwner('bob') | 9 | const bob = await createOwner('bob') |
| 10 | const guild = await createGuild('main', alice.user_id) | 10 | const guild = await createGuild('main', alice.user_id) |
| @@ -93,12 +93,29 @@ await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, cre | @@ -93,12 +93,29 @@ await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, cre | ||
| 93 | const noPending = await call('POST', `/v1/tasks/${moving.id}/transfer/accept`, { token: taker.key }) | 93 | const noPending = await call('POST', `/v1/tasks/${moving.id}/transfer/accept`, { token: taker.key }) |
| 94 | check('没有待确认转让时接受 → 409', noPending.status === 409, noPending) | 94 | check('没有待确认转让时接受 → 409', noPending.status === 409, noPending) |
| 95 | 95 | ||
| 96 | - // F2-07 交付物只接受链接;提交后进入待验收。 | 96 | + // F2-07 交付物:上传到平台的文件或 https 外链;提交后进入待验收。 |
| 97 | const delivering = tasks[2] | 97 | const delivering = tasks[2] |
| 98 | const notLink = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'file', url: 'https://example.com/a.zip' }] } }) | 98 | const notLink = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'file', url: 'https://example.com/a.zip' }] } }) |
| 99 | const httpLink = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'link', url: 'http://example.com/a' }] } }) | 99 | const httpLink = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [{ type: 'link', url: 'http://example.com/a' }] } }) |
| 100 | - check('F2-07 非链接或非 https 交付物 → 400', notLink.status === 400 && httpLink.status === 400, { notLink, httpLink }) | ||
| 101 | - const submitted = await submit(taker, delivering.id) | 100 | + const noArtifacts = await call('POST', `/v1/tasks/${delivering.id}/submit`, { token: taker.key, body: { summary: 'done', artifacts: [] } }) |
| 101 | + check('F2-07 格式不对的交付物、非 https 链接、没有交付物 → 400,提示先上传文件', notLink.status === 400 && httpLink.status === 400 && noArtifacts.status === 400 && noArtifacts.json.error.hint.includes('/files'), { notLink, httpLink, noArtifacts }) | ||
| 102 | + const article = '# How to reset a password\n\n1. Open Settings.\n2. Choose Reset password.\n' | ||
| 103 | + const uploaded = await uploadFile(taker, delivering.id, '../../reset password.md', article) | ||
| 104 | + 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) | ||
| 105 | + const strangerUpload = await uploadFile(rival, delivering.id, 'x.md', 'x') | ||
| 106 | + const emptyUpload = await uploadFile(taker, delivering.id, 'empty.md', '') | ||
| 107 | + check('非持有人上传 → 403;空文件 → 422', strangerUpload.status === 403 && emptyUpload.status === 422, { strangerUpload, emptyUpload }) | ||
| 108 | + const rivalFile = await uploadFile(rival, working.id, 'mine.md', 'rival work') | ||
| 109 | + 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 }] } }) | ||
| 110 | + check('提交里引用别人或别的任务的文件 → 422', rivalFile.status === 200 && borrowed.status === 422, { borrowed }) | ||
| 111 | + const beforeSubmit = await downloadFile(publisher, uploaded.json.data.file_id) | ||
| 112 | + check('上传者能下载自己的文件;别的成员取不到', (await downloadFile(taker, uploaded.json.data.file_id)).text === article && (await downloadFile(rival, uploaded.json.data.file_id)).status === 404, beforeSubmit) | ||
| 113 | + 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' }] } }) | ||
| 114 | + const seen = (await call('GET', `/v1/tasks/${delivering.id}`, { token: publisher.key })).json.data.submissions[0].artifacts[0] | ||
| 115 | + const fetched = await downloadFile(publisher, uploaded.json.data.file_id) | ||
| 116 | + check('发布方在任务详情里看到文件名与大小,并能下载到原文(以附件形式)', seen.type === 'file' && seen.filename === 'reset password.md' && seen.size_bytes === Buffer.byteLength(article) && fetched.text === article && fetched.disposition.startsWith('attachment'), { seen, fetched }) | ||
| 117 | + const lateUpload = await uploadFile(taker, delivering.id, 'late.md', 'late') | ||
| 118 | + check('提交后不能再上传 → 409', lateUpload.status === 409, lateUpload) | ||
| 102 | const publisherHome = await call('GET', '/v1/home', { token: publisher.key }) | 119 | const publisherHome = await call('GET', '/v1/home', { token: publisher.key }) |
| 103 | check('F2-07 提交 → submitted,发布方 /home 的 to_review 出现', submitted.status === 200 && submitted.json.data.status === 'submitted' && typeof submitted.json.data.review_due === 'string' && publisherHome.json.data.to_review.some((t) => t.id === delivering.id), { submitted, to_review: publisherHome.json.data.to_review }) | 120 | 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 }) |
| 104 | 121 |
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 | @@ -107,6 +107,28 @@ await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuil | ||
| 107 | const contestPayouts = await db('vk_escrow_payouts').where({ task_id: contest.id }) | 107 | const contestPayouts = await db('vk_escrow_payouts').where({ task_id: contest.id }) |
| 108 | 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 }) | 108 | 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 }) |
| 109 | 109 | ||
| 110 | + // 未结算前持有人在任何状态都能放弃:已提交、返工中、仲裁中。 | ||
| 111 | + const fickle = (await publish(publisher, { reward: 20, acceptance: items })).json.data | ||
| 112 | + await claim(taker, fickle.id) | ||
| 113 | + await submit(taker, fickle.id) | ||
| 114 | + const afterSubmit = await call('POST', `/v1/tasks/${fickle.id}/release`, { token: taker.key }) | ||
| 115 | + const lateReview = await review(publisher, fickle.id, 'accept', ids) | ||
| 116 | + check('已提交后放弃 → 回到 open,发布方再验收 → 409', afterSubmit.status === 200 && afterSubmit.json.data.status === 'open' && afterSubmit.json.data.holder_agent_id === null && lateReview.status === 409, { afterSubmit, lateReview }) | ||
| 117 | + await claim(helper, fickle.id) | ||
| 118 | + await submit(helper, fickle.id) | ||
| 119 | + await review(publisher, fickle.id, 'reject', ids, ['c1'], { reason: 'Not natural.' }) | ||
| 120 | + const fromRework = await call('POST', `/v1/tasks/${fickle.id}/release`, { token: helper.key }) | ||
| 121 | + check('返工中放弃 → 回到 open,返工次数清零', fromRework.status === 200 && fromRework.json.data.status === 'open' && fromRework.json.data.rework_count === 0, fromRework) | ||
| 122 | + await claim(taker, fickle.id) | ||
| 123 | + await submit(taker, fickle.id) | ||
| 124 | + await call('POST', `/v1/tasks/${fickle.id}/dispute`, { token: taker.key }) | ||
| 125 | + const fromDispute = await call('POST', `/v1/tasks/${fickle.id}/release`, { token: taker.key }) | ||
| 126 | + const closedDispute = await db('vk_disputes').where({ task_id: fickle.id }).first() | ||
| 127 | + 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 }) | ||
| 128 | + const settledRelease = await call('POST', `/v1/tasks/${first.id}/release`, { token: taker.key }) | ||
| 129 | + check('已结算的任务不能放弃 → 409', settledRelease.status === 409, settledRelease) | ||
| 130 | + check('撤回无人在做的任务,托管退回', (await call('POST', `/v1/tasks/${fickle.id}/cancel`, { token: publisher.key })).json.data.status === 'cancelled') | ||
| 131 | + | ||
| 110 | // F3-06 打赏:打赏人减、收款人加;重放不重复;余额不足 → 402;未结算任务与打赏自己被拒。 | 132 | // F3-06 打赏:打赏人减、收款人加;重放不重复;余额不足 → 402;未结算任务与打赏自己被拒。 |
| 111 | const tipKey = randomUUID() | 133 | const tipKey = randomUUID() |
| 112 | const tipperBefore = (await wallet(helper)).available | 134 | const tipperBefore = (await wallet(helper)).available |
packages/voko-exchange/src/db/tables/index.cjs
| @@ -17,6 +17,7 @@ const tableModules = [ | @@ -17,6 +17,7 @@ const tableModules = [ | ||
| 17 | require('./vk_events.cjs'), | 17 | require('./vk_events.cjs'), |
| 18 | require('./vk_jobs.cjs'), | 18 | require('./vk_jobs.cjs'), |
| 19 | require('./vk_agent_titles.cjs'), | 19 | require('./vk_agent_titles.cjs'), |
| 20 | + require('./vk_files.cjs'), | ||
| 20 | ] | 21 | ] |
| 21 | 22 | ||
| 22 | const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) | 23 | const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) |
packages/voko-exchange/src/db/tables/vk_files.cjs
0 → 100644
| 1 | +const tableName = 'vk_files' | ||
| 2 | + | ||
| 3 | +function buildTable(t) { | ||
| 4 | + t.string('id', 64).primary() | ||
| 5 | + t.string('task_id', 64).notNullable() | ||
| 6 | + t.string('agent_id', 64).notNullable() | ||
| 7 | + t.string('filename', 255).notNullable() | ||
| 8 | + t.integer('size_bytes').unsigned().notNullable() | ||
| 9 | + t.string('sha256', 64).notNullable() | ||
| 10 | + t.datetime('created_at', { precision: 3 }).notNullable() | ||
| 11 | + t.index(['task_id', 'agent_id'], 'idx_vk_files_task_agent') | ||
| 12 | + t.check('size_bytes > 0', [], 'ck_vk_files_size') | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +module.exports = { tableName, buildTable } |
packages/voko-exchange/src/db/tables/vk_submissions.cjs
| @@ -9,7 +9,7 @@ function buildTable(t) { | @@ -9,7 +9,7 @@ function buildTable(t) { | ||
| 9 | t.json('artifacts').notNullable() | 9 | t.json('artifacts').notNullable() |
| 10 | t.datetime('created_at', { precision: 3 }).notNullable() | 10 | t.datetime('created_at', { precision: 3 }).notNullable() |
| 11 | t.unique(['task_id', 'agent_id', 'round'], { indexName: 'uq_vk_submissions_task_agent_round' }) | 11 | t.unique(['task_id', 'agent_id', 'round'], { indexName: 'uq_vk_submissions_task_agent_round' }) |
| 12 | - t.check('round between 1 and 3', [], 'ck_vk_submissions_round') | 12 | + t.check('round >= 1', [], 'ck_vk_submissions_round') |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | module.exports = { tableName, buildTable } | 15 | module.exports = { tableName, buildTable } |
packages/voko-exchange/src/files.js
0 → 100644
| 1 | +import { createHash, randomUUID } from 'node:crypto' | ||
| 2 | + | ||
| 3 | +import { taskFromRow } from './task.js' | ||
| 4 | + | ||
| 5 | +const FILES = 'vk_files' | ||
| 6 | +export const MAX_FILE_BYTES = 10 * 1024 * 1024 | ||
| 7 | +const MAX_FILES_PER_AGENT_TASK = 20 | ||
| 8 | +/** 裁判(大模型)能直接读的文本类文件:按扩展名判断,且不超过这个大小。 */ | ||
| 9 | +const TEXT_EXTENSIONS = /\.(md|markdown|txt|csv|json|html?|xml|ya?ml)$/i | ||
| 10 | +const MAX_TEXT_BYTES = 64 * 1024 | ||
| 11 | + | ||
| 12 | +/** | ||
| 13 | + * 交付文件:Agent 把交付物上传到平台,提交时引用;发布方(及平台裁判)下载查看。 | ||
| 14 | + * | ||
| 15 | + * @param {{ | ||
| 16 | + * db: import('knex').Knex, | ||
| 17 | + * store: { put(file_id: string, body: Buffer): Promise<void>, get(file_id: string): Promise<Buffer> }, | ||
| 18 | + * }} deps `store` 是文件内容的存储(本地磁盘或对象存储),由 composition root 注入 | ||
| 19 | + */ | ||
| 20 | +export function createFiles({ db, store }) { | ||
| 21 | + if (typeof db !== 'function') throw new Error('createFiles: db is required') | ||
| 22 | + | ||
| 23 | + /** | ||
| 24 | + * 上传一个交付文件。只有正在做这个任务的 Agent(计件活的持有人、擂台赛的参赛者)可以上传。 | ||
| 25 | + * @param {object} _ctx | ||
| 26 | + * @param {{ agent: import('./agents.js').Agent, task_id: string, filename: string, body: Buffer }} input | ||
| 27 | + * @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 }>} | ||
| 28 | + */ | ||
| 29 | + async function upload(_ctx, { agent, task_id, filename, body }) { | ||
| 30 | + if (body.length === 0) return refuse('rule_violation', 'The file is empty.') | ||
| 31 | + if (body.length > MAX_FILE_BYTES) return refuse('rule_violation', `The file is larger than ${MAX_FILE_BYTES} bytes.`) | ||
| 32 | + const row = await db('vk_tasks').where({ id: task_id }).first() | ||
| 33 | + if (row === undefined) return refuse('not_found', `Task not found: ${task_id}`) | ||
| 34 | + const task = taskFromRow(row) | ||
| 35 | + if (task.guild_id !== agent.guild_id) return refuse('forbidden', 'You are not a member of this guild.') | ||
| 36 | + switch (task.status) { | ||
| 37 | + case 'claimed': | ||
| 38 | + case 'rework': | ||
| 39 | + if (task.holder_agent_id !== agent.id) return refuse('forbidden', 'Only the current holder can upload deliverables for this task.') | ||
| 40 | + break | ||
| 41 | + case 'in_contest': | ||
| 42 | + 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.') | ||
| 43 | + break | ||
| 44 | + case 'pending_approval': | ||
| 45 | + case 'open': | ||
| 46 | + case 'submitted': | ||
| 47 | + case 'reviewing': | ||
| 48 | + case 'disputed': | ||
| 49 | + case 'settled': | ||
| 50 | + case 'cancelled': | ||
| 51 | + case 'refunded': | ||
| 52 | + return refuse('state_conflict', `Task is ${task.status}; deliverables can only be uploaded while you are working on it.`) | ||
| 53 | + default: | ||
| 54 | + throw new Error(`Unhandled status: ${task.status}, id: ${task.id}`) | ||
| 55 | + } | ||
| 56 | + const count = await db(FILES).where({ task_id, agent_id: agent.id }).count({ n: '*' }).first() | ||
| 57 | + if (Number(count.n) >= MAX_FILES_PER_AGENT_TASK) return refuse('rule_violation', `At most ${MAX_FILES_PER_AGENT_TASK} files per task.`) | ||
| 58 | + 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() } | ||
| 59 | + // 先存内容再记元数据:记了元数据就一定取得到内容。 | ||
| 60 | + await store.put(file.id, body) | ||
| 61 | + await db(FILES).insert(file) | ||
| 62 | + return { ok: true, file: { file_id: file.id, filename: file.filename, size_bytes: file.size_bytes, sha256: file.sha256 } } | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + /** | ||
| 66 | + * 下载。上传者本人随时可取;发布方在任务不处于擂台赛封存期时可取;其他人按不存在处理。 | ||
| 67 | + * @param {object} _ctx | ||
| 68 | + * @param {{ agent: import('./agents.js').Agent, file_id: string }} input | ||
| 69 | + * @returns {Promise<{ ok: true, filename: string, body: Buffer } | { ok: false, code: 'not_found', hint: string }>} | ||
| 70 | + */ | ||
| 71 | + async function download(_ctx, { agent, file_id }) { | ||
| 72 | + const file = await db(FILES).where({ id: file_id }).first() | ||
| 73 | + if (file === undefined) return refuse('not_found', `File not found: ${file_id}`) | ||
| 74 | + if (file.agent_id !== agent.id) { | ||
| 75 | + const task = await db('vk_tasks').where({ id: file.task_id }).first('publisher_agent_id', 'status') | ||
| 76 | + if (task.publisher_agent_id !== agent.id || task.status === 'in_contest') return refuse('not_found', `File not found: ${file_id}`) | ||
| 77 | + } | ||
| 78 | + return { ok: true, filename: file.filename, body: await store.get(file.id) } | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + /** | ||
| 82 | + * 给平台裁判读的正文:小的文本类文件返回内容,其余返回 null(裁判只能看到文件名与大小)。 | ||
| 83 | + * @param {string} file_id | ||
| 84 | + * @returns {Promise<string|null>} | ||
| 85 | + */ | ||
| 86 | + async function textOf(file_id) { | ||
| 87 | + const file = await db(FILES).where({ id: file_id }).first('id', 'filename', 'size_bytes') | ||
| 88 | + if (file === undefined) throw new Error(`vk_files row missing, id: ${file_id}`) | ||
| 89 | + if (!TEXT_EXTENSIONS.test(file.filename) || file.size_bytes > MAX_TEXT_BYTES) return null | ||
| 90 | + return (await store.get(file.id)).toString('utf8') | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + return { upload, download, textOf } | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +/** | ||
| 97 | + * 只保留文件名本身:去掉路径与控制字符,限制长度。文件名只用于展示与下载时的建议名,不参与存储路径。 | ||
| 98 | + * @param {string} filename | ||
| 99 | + * @returns {string} | ||
| 100 | + */ | ||
| 101 | +function safeFilename(filename) { | ||
| 102 | + // eslint-disable-next-line no-control-regex | ||
| 103 | + const base = filename.split(/[\\/]/).pop().replace(/[\u0000-\u001f\u007f"]/g, '').trim() | ||
| 104 | + return (base === '' || base === '.' || base === '..' ? 'file' : base).slice(-200) | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +/** | ||
| 108 | + * @template {string} C | ||
| 109 | + * @param {C} code | ||
| 110 | + * @param {string} hint | ||
| 111 | + * @returns {{ ok: false, code: C, hint: string }} | ||
| 112 | + */ | ||
| 113 | +function refuse(code, hint) { | ||
| 114 | + return { ok: false, code, hint } | ||
| 115 | +} |
packages/voko-exchange/src/index.js
| @@ -12,6 +12,7 @@ export { createTasks } from './tasks.js' | @@ -12,6 +12,7 @@ export { createTasks } from './tasks.js' | ||
| 12 | export { createEvents } from './events.js' | 12 | export { createEvents } from './events.js' |
| 13 | export { createJobs } from './jobs.js' | 13 | export { createJobs } from './jobs.js' |
| 14 | export { createHelp } from './help.js' | 14 | export { createHelp } from './help.js' |
| 15 | +export { createFiles, MAX_FILE_BYTES } from './files.js' | ||
| 15 | export { createGame } from './game.js' | 16 | export { createGame } from './game.js' |
| 16 | export { levelForXp, weekStart, MAX_LEVEL } from './level.js' | 17 | export { levelForXp, weekStart, MAX_LEVEL } from './level.js' |
| 17 | export { createJudge } from './judge.js' | 18 | export { createJudge } from './judge.js' |
packages/voko-exchange/src/split-relay.js
| 1 | /** | 1 | /** |
| 2 | * 接力分配(2026-10-09 定:平分)。参与分配的人 = 最终提交者 + 所有留下过有效检查点后离开的前任 | 2 | * 接力分配(2026-10-09 定:平分)。参与分配的人 = 最终提交者 + 所有留下过有效检查点后离开的前任 |
| 3 | - * (has_valid_checkpoint 且 end_reason ∈ timeout / released / transferred);同一 Agent 多次持有只算一份。 | 3 | + * (has_valid_checkpoint 且持有已结束:timeout / released / transferred,或提交后又放弃);同一 Agent 多次持有只算一份。 |
| 4 | * 赏金按人数平分,除不尽的余数归最终提交者。各笔之和必须等于 reward。 | 4 | * 赏金按人数平分,除不尽的余数归最终提交者。各笔之和必须等于 reward。 |
| 5 | * | 5 | * |
| 6 | * @param {number} reward 正整数 | 6 | * @param {number} reward 正整数 |
| @@ -13,12 +13,13 @@ export function splitRelayPayout(reward, holds, final_agent_id) { | @@ -13,12 +13,13 @@ export function splitRelayPayout(reward, holds, final_agent_id) { | ||
| 13 | const relays = new Set() | 13 | const relays = new Set() |
| 14 | for (const hold of holds) { | 14 | for (const hold of holds) { |
| 15 | switch (hold.end_reason) { | 15 | switch (hold.end_reason) { |
| 16 | + // 提交过又放弃的前任(其持有以 submitted 结束)同样算留下过工作。 | ||
| 16 | case 'released': | 17 | case 'released': |
| 17 | case 'timeout': | 18 | case 'timeout': |
| 18 | case 'transferred': | 19 | case 'transferred': |
| 20 | + case 'submitted': | ||
| 19 | if (hold.has_valid_checkpoint && hold.agent_id !== final_agent_id) relays.add(hold.agent_id) | 21 | if (hold.has_valid_checkpoint && hold.agent_id !== final_agent_id) relays.add(hold.agent_id) |
| 20 | break | 22 | break |
| 21 | - case 'submitted': | ||
| 22 | case null: | 23 | case null: |
| 23 | break | 24 | break |
| 24 | default: | 25 | default: |
packages/voko-exchange/src/tasks.js
| @@ -42,9 +42,10 @@ const JOBS = 'vk_jobs' | @@ -42,9 +42,10 @@ const JOBS = 'vk_jobs' | ||
| 42 | * spentToday(ctx: object, trx: import('knex').Knex, input: { wallet_user_id: string, now: Date }): Promise<number>, | 42 | * spentToday(ctx: object, trx: import('knex').Knex, input: { wallet_user_id: string, now: Date }): Promise<number>, |
| 43 | * }, | 43 | * }, |
| 44 | * judge: ReturnType<typeof import('./judge.js').createJudge>, | 44 | * judge: ReturnType<typeof import('./judge.js').createJudge>, |
| 45 | - * }} deps | 45 | + * fileText(file_id: string): Promise<string|null>, |
| 46 | + * }} deps `fileText` 取交付文件的正文给裁判读(非文本或太大时为 null) | ||
| 46 | */ | 47 | */ |
| 47 | -export function createTasks({ db, agents, escrow, judge }) { | 48 | +export function createTasks({ db, agents, escrow, judge, fileText }) { |
| 48 | if (typeof db !== 'function') throw new Error('createTasks: db is required') | 49 | if (typeof db !== 'function') throw new Error('createTasks: db is required') |
| 49 | 50 | ||
| 50 | /** | 51 | /** |
| @@ -164,7 +165,8 @@ export function createTasks({ db, agents, escrow, judge }) { | @@ -164,7 +165,8 @@ export function createTasks({ db, agents, escrow, judge }) { | ||
| 164 | async function apply(ctx, trx, task, action, actor, idempotency_key, now, record) { | 165 | async function apply(ctx, trx, task, action, actor, idempotency_key, now, record) { |
| 165 | const guild = await guildRow(trx, task.guild_id) | 166 | const guild = await guildRow(trx, task.guild_id) |
| 166 | const publisher = await agents.getById(ctx, trx, { agent_id: task.publisher_agent_id }) | 167 | const publisher = await agents.getById(ctx, trx, { agent_id: task.publisher_agent_id }) |
| 167 | - const result = transition({ task, guild, publisher_owner_user_id: /** @type {string} */ (publisher.owner_user_id) }, action, actor, now) | 168 | + const entries = await trx(ENTRIES).where({ task_id: task.id }).count({ n: '*' }).first() |
| 169 | + const result = transition({ task, guild, publisher_owner_user_id: /** @type {string} */ (publisher.owner_user_id), entry_count: Number(entries.n) }, action, actor, now) | ||
| 168 | if (!result.ok) return result | 170 | if (!result.ok) return result |
| 169 | const next = result.task | 171 | const next = result.task |
| 170 | await trx(TASKS).where({ id: task.id }).update({ | 172 | await trx(TASKS).where({ id: task.id }).update({ |
| @@ -194,6 +196,17 @@ export function createTasks({ db, agents, escrow, judge }) { | @@ -194,6 +196,17 @@ export function createTasks({ db, agents, escrow, judge }) { | ||
| 194 | case 'entry_add': | 196 | case 'entry_add': |
| 195 | await trx(ENTRIES).insert({ id: `en_${randomUUID()}`, task_id: task.id, agent_id: effect.agent_id, created_at: now }) | 197 | await trx(ENTRIES).insert({ id: `en_${randomUUID()}`, task_id: task.id, agent_id: effect.agent_id, created_at: now }) |
| 196 | break | 198 | break |
| 199 | + case 'entry_remove': | ||
| 200 | + // 退赛:报名与(封存中的)提交一并撤掉。 | ||
| 201 | + await trx(SUBMISSIONS).where({ task_id: task.id, agent_id: effect.agent_id }).del() | ||
| 202 | + await trx(ENTRIES).where({ task_id: task.id, agent_id: effect.agent_id }).del() | ||
| 203 | + break | ||
| 204 | + case 'dispute_close': { | ||
| 205 | + // 持有人放弃,争议不再需要裁决;记为由该 Agent 的主人关闭。 | ||
| 206 | + const releasing = await agents.getById(ctx, trx, { agent_id: effect.agent_id }) | ||
| 207 | + 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 }) | ||
| 208 | + break | ||
| 209 | + } | ||
| 197 | case 'escrow_topup': | 210 | case 'escrow_topup': |
| 198 | await escrow.topup(ctx, trx, { task_id: task.id, amount: effect.amount, idempotency_key: /** @type {string} */ (idempotency_key) }) | 211 | await escrow.topup(ctx, trx, { task_id: task.id, amount: effect.amount, idempotency_key: /** @type {string} */ (idempotency_key) }) |
| 199 | break | 212 | break |
| @@ -373,23 +386,37 @@ export function createTasks({ db, agents, escrow, judge }) { | @@ -373,23 +386,37 @@ export function createTasks({ db, agents, escrow, judge }) { | ||
| 373 | } | 386 | } |
| 374 | 387 | ||
| 375 | /** | 388 | /** |
| 376 | - * 提交交付物。计件活每轮一份(第 n 次返工是第 n+1 轮);擂台赛每个参赛者一份,截止前封存。 | 389 | + * 提交交付物。计件活每次提交一轮(返工重交、放弃后再接再交都递增);擂台赛每个参赛者一份,截止前封存。 |
| 377 | * @param {object} ctx | 390 | * @param {object} ctx |
| 378 | - * @param {{ agent: Agent, task_id: string, summary: string, artifacts: Array<{ type: 'link', url: string, note?: string }> }} input | 391 | + * @param {{ agent: Agent, task_id: string, summary: string, artifacts: Array<{ type: 'link', url: string, note?: string } | { type: 'file', file_id: string, note?: string }> }} input 文件须是本人为这个任务上传的 |
| 379 | */ | 392 | */ |
| 380 | async function submit(ctx, { agent, task_id, summary, artifacts }) { | 393 | async function submit(ctx, { agent, task_id, summary, artifacts }) { |
| 394 | + /** @type {Array<Record<string, unknown>>} */ | ||
| 395 | + let stored = [] | ||
| 381 | return act(ctx, { | 396 | return act(ctx, { |
| 382 | agent, | 397 | agent, |
| 383 | task_id, | 398 | task_id, |
| 384 | action: { type: 'submit' }, | 399 | action: { type: 'submit' }, |
| 385 | async prepare(trx, task) { | 400 | async prepare(trx, task) { |
| 401 | + const ids = artifacts.filter((artifact) => artifact.type === 'file').map((artifact) => artifact.file_id) | ||
| 402 | + const files = ids.length === 0 ? [] : await trx('vk_files').whereIn('id', ids).where({ task_id, agent_id: agent.id }).select('id', 'filename', 'size_bytes') | ||
| 403 | + const unknown = ids.filter((id) => !files.some((file) => file.id === id)) | ||
| 404 | + if (unknown.length > 0) return refuse('rule_violation', `These file_ids are not files you uploaded for this task: ${unknown.join(', ')}.`) | ||
| 405 | + // 文件类交付物连同文件名与大小一起记进提交,之后展示不必再查文件表。 | ||
| 406 | + stored = artifacts.map((artifact) => { | ||
| 407 | + if (artifact.type !== 'file') return artifact | ||
| 408 | + const file = files.find((item) => item.id === artifact.file_id) | ||
| 409 | + return { ...artifact, filename: file.filename, size_bytes: file.size_bytes } | ||
| 410 | + }) | ||
| 386 | if (task.template !== 'contest') return null | 411 | if (task.template !== 'contest') return null |
| 387 | const existing = await trx(SUBMISSIONS).where({ task_id, agent_id: agent.id }).first('id') | 412 | const existing = await trx(SUBMISSIONS).where({ task_id, agent_id: agent.id }).first('id') |
| 388 | return existing === undefined ? null : refuse('state_conflict', 'You have already submitted to this contest.') | 413 | return existing === undefined ? null : refuse('state_conflict', 'You have already submitted to this contest.') |
| 389 | }, | 414 | }, |
| 390 | async record(trx, before, _after, now) { | 415 | async record(trx, before, _after, now) { |
| 391 | const id = `s_${randomUUID()}` | 416 | const id = `s_${randomUUID()}` |
| 392 | - 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 }) | 417 | + // 轮次按这个 Agent 在这个任务上已有的提交数递增:返工重交、放弃后再接再交都各占一轮。 |
| 418 | + const prior = await trx(SUBMISSIONS).where({ task_id, agent_id: agent.id }).max({ round: 'round' }).first() | ||
| 419 | + await trx(SUBMISSIONS).insert({ id, task_id, agent_id: agent.id, round: (prior.round ?? 0) + 1, summary, artifacts: JSON.stringify(stored), created_at: now }) | ||
| 393 | return { submission_id: id } | 420 | return { submission_id: id } |
| 394 | }, | 421 | }, |
| 395 | }) | 422 | }) |
| @@ -567,6 +594,14 @@ export function createTasks({ db, agents, escrow, judge }) { | @@ -567,6 +594,14 @@ export function createTasks({ db, agents, escrow, judge }) { | ||
| 567 | } | 594 | } |
| 568 | 595 | ||
| 569 | /** | 596 | /** |
| 597 | + * 给裁判看的交付物:文本类小文件附上正文,其余只有文件名与大小。 | ||
| 598 | + * @param {Array<Record<string, any>>} artifacts | ||
| 599 | + */ | ||
| 600 | + async function readable(artifacts) { | ||
| 601 | + 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))) | ||
| 602 | + } | ||
| 603 | + | ||
| 604 | + /** | ||
| 570 | * worker:擂台赛验收窗口到期,由大模型按清单选赢家后结算;没有提交或无人全部通过则退款。 | 605 | * worker:擂台赛验收窗口到期,由大模型按清单选赢家后结算;没有提交或无人全部通过则退款。 |
| 571 | * 模型调用在事务外进行(不占任务行锁);随后的状态机会重新检查任务状态与到期时间。 | 606 | * 模型调用在事务外进行(不占任务行锁);随后的状态机会重新检查任务状态与到期时间。 |
| 572 | * @param {object} ctx | 607 | * @param {object} ctx |
| @@ -580,7 +615,7 @@ export function createTasks({ db, agents, escrow, judge }) { | @@ -580,7 +615,7 @@ export function createTasks({ db, agents, escrow, judge }) { | ||
| 580 | const submissions = await db(SUBMISSIONS).where({ task_id }).select('id', 'agent_id', 'summary', 'artifacts') | 615 | const submissions = await db(SUBMISSIONS).where({ task_id }).select('id', 'agent_id', 'summary', 'artifacts') |
| 581 | const verdict = submissions.length === 0 | 616 | const verdict = submissions.length === 0 |
| 582 | ? { winner_submission_id: null, checks: [], reason: 'No submissions.' } | 617 | ? { winner_submission_id: null, checks: [], reason: 'No submissions.' } |
| 583 | - : await judge.pickContestWinner({ task, acceptance: await acceptanceOf(db, task_id), submissions: submissions.map((item) => ({ id: item.id, summary: item.summary, artifacts: item.artifacts })) }) | 618 | + : 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) }))) }) |
| 584 | const winner = submissions.find((item) => item.id === verdict.winner_submission_id) | 619 | const winner = submissions.find((item) => item.id === verdict.winner_submission_id) |
| 585 | await actAsWorker(ctx, { | 620 | await actAsWorker(ctx, { |
| 586 | task_id, | 621 | task_id, |
| @@ -612,7 +647,7 @@ export function createTasks({ db, agents, escrow, judge }) { | @@ -612,7 +647,7 @@ export function createTasks({ db, agents, escrow, judge }) { | ||
| 612 | const proposal = await judge.proposeArbitration({ | 647 | const proposal = await judge.proposeArbitration({ |
| 613 | task, | 648 | task, |
| 614 | acceptance: await acceptanceOf(db, task_id), | 649 | acceptance: await acceptanceOf(db, task_id), |
| 615 | - submissions, | 650 | + submissions: await Promise.all(submissions.map(async (item) => ({ ...item, artifacts: await readable(item.artifacts) }))), |
| 616 | 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 })) })), | 651 | 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 })) })), |
| 617 | comments, | 652 | comments, |
| 618 | }) | 653 | }) |
packages/voko-exchange/src/transition.js
| @@ -47,7 +47,8 @@ const HOUR_MS = 3600_000 | @@ -47,7 +47,8 @@ const HOUR_MS = 3600_000 | ||
| 47 | * task: Task, | 47 | * task: Task, |
| 48 | * guild: { checkpoint_interval_sec: number, piece_review_window_sec: number, contest_review_window_sec: number }, | 48 | * guild: { checkpoint_interval_sec: number, piece_review_window_sec: number, contest_review_window_sec: number }, |
| 49 | * publisher_owner_user_id: string, | 49 | * publisher_owner_user_id: string, |
| 50 | - * }} TaskState 任务行 + 所属公会的时间规则 + 发布方 Agent 的主人,由 service 在事务内读出。 | 50 | + * entry_count: number, |
| 51 | + * }} TaskState 任务行 + 所属公会的时间规则 + 发布方 Agent 的主人 + 擂台赛当前报名数,由 service 在事务内读出。 | ||
| 51 | * | 52 | * |
| 52 | * @typedef {{ kind: 'agent', agent_id: string, owner_user_id: string, max_rarity: number, active_hold_count: number, is_entrant: boolean }} AgentActor | 53 | * @typedef {{ kind: 'agent', agent_id: string, owner_user_id: string, max_rarity: number, active_hold_count: number, is_entrant: boolean }} AgentActor |
| 53 | * @typedef {AgentActor | { kind: 'lead', user_id: string } | { kind: 'operator', user_id: string } | { kind: 'worker' }} Actor | 54 | * @typedef {AgentActor | { kind: 'lead', user_id: string } | { kind: 'operator', user_id: string } | { kind: 'worker' }} Actor |
| @@ -66,6 +67,8 @@ const HOUR_MS = 3600_000 | @@ -66,6 +67,8 @@ const HOUR_MS = 3600_000 | ||
| 66 | * { type: 'hold_end', reason: 'submitted'|'released'|'timeout'|'transferred' } | | 67 | * { type: 'hold_end', reason: 'submitted'|'released'|'timeout'|'transferred' } | |
| 67 | * { type: 'hold_checkpoint' } | | 68 | * { type: 'hold_checkpoint' } | |
| 68 | * { type: 'entry_add', agent_id: string } | | 69 | * { type: 'entry_add', agent_id: string } | |
| 70 | + * { type: 'entry_remove', agent_id: string } | | ||
| 71 | + * { type: 'dispute_close', agent_id: string } | | ||
| 69 | * { type: 'escrow_topup', amount: number } | | 72 | * { type: 'escrow_topup', amount: number } | |
| 70 | * { type: 'escrow_refund' } | | 73 | * { type: 'escrow_refund' } | |
| 71 | * { type: 'escrow_settle', winner_agent_id: string } | | 74 | * { type: 'escrow_settle', winner_agent_id: string } | |
| @@ -227,6 +230,11 @@ const TRANSITIONS = { | @@ -227,6 +230,11 @@ const TRANSITIONS = { | ||
| 227 | // 提交封存到截止,不通知发布方。 | 230 | // 提交封存到截止,不通知发布方。 |
| 228 | return ok(task, []) | 231 | return ok(task, []) |
| 229 | }, | 232 | }, |
| 233 | + // 参赛者退赛:撤掉自己的报名与提交;最后一个人退出后任务回到 open。 | ||
| 234 | + release({ task, entry_count }, _action, actor) { | ||
| 235 | + if (actor.kind !== 'agent' || !actor.is_entrant) return notAllowed('Only an entrant can withdraw from a contest.') | ||
| 236 | + return ok(entry_count <= 1 ? { ...task, status: 'open' } : task, [{ type: 'entry_remove', agent_id: actor.agent_id }]) | ||
| 237 | + }, | ||
| 230 | contest_close(state, _action, actor, now) { | 238 | contest_close(state, _action, actor, now) { |
| 231 | const { task } = state | 239 | const { task } = state |
| 232 | if (actor.kind !== 'worker') return notAllowed('Only the platform can close a contest.') | 240 | if (actor.kind !== 'worker') return notAllowed('Only the platform can close a contest.') |
| @@ -260,6 +268,7 @@ const TRANSITIONS = { | @@ -260,6 +268,7 @@ const TRANSITIONS = { | ||
| 260 | return ok({ ...task, status: 'rework', rework_count: task.rework_count + 1, review_due_at: null }, [event('task.rejected', holder)]) | 268 | return ok({ ...task, status: 'rework', rework_count: task.rework_count + 1, review_due_at: null }, [event('task.rejected', holder)]) |
| 261 | }, | 269 | }, |
| 262 | dispute: openDispute, | 270 | dispute: openDispute, |
| 271 | + release: holderRelease, | ||
| 263 | }, | 272 | }, |
| 264 | 273 | ||
| 265 | reviewing: { | 274 | reviewing: { |
| @@ -287,6 +296,7 @@ const TRANSITIONS = { | @@ -287,6 +296,7 @@ const TRANSITIONS = { | ||
| 287 | return ok(...submitted(state, now, [])) | 296 | return ok(...submitted(state, now, [])) |
| 288 | }, | 297 | }, |
| 289 | dispute: openDispute, | 298 | dispute: openDispute, |
| 299 | + release: holderRelease, | ||
| 290 | }, | 300 | }, |
| 291 | 301 | ||
| 292 | disputed: { | 302 | disputed: { |
| @@ -303,6 +313,12 @@ const TRANSITIONS = { | @@ -303,6 +313,12 @@ const TRANSITIONS = { | ||
| 303 | throw new Error(`Unhandled outcome: ${action.outcome}, id: ${task.id}`) | 313 | throw new Error(`Unhandled outcome: ${action.outcome}, id: ${task.id}`) |
| 304 | } | 314 | } |
| 305 | }, | 315 | }, |
| 316 | + // 持有人在仲裁中放弃:争议随之关闭,任务回到看板。 | ||
| 317 | + release({ task }, _action, actor, now) { | ||
| 318 | + if (!isHolder(task, actor) || actor.kind !== 'agent') return notAllowed('Only the current holder can release.') | ||
| 319 | + const reopened = reopen(task, now) | ||
| 320 | + return ok(reopened, [{ type: 'dispute_close', agent_id: actor.agent_id }, ...releaseEffects(task, reopened, 'released')]) | ||
| 321 | + }, | ||
| 306 | }, | 322 | }, |
| 307 | 323 | ||
| 308 | settled: {}, | 324 | settled: {}, |
| @@ -312,6 +328,15 @@ const TRANSITIONS = { | @@ -312,6 +328,15 @@ const TRANSITIONS = { | ||
| 312 | 328 | ||
| 313 | if (Object.keys(TRANSITIONS).length !== TASK_STATUSES.length) throw new Error('TRANSITIONS must cover every task status') | 329 | if (Object.keys(TRANSITIONS).length !== TASK_STATUSES.length) throw new Error('TRANSITIONS must cover every task status') |
| 314 | 330 | ||
| 331 | +/** | ||
| 332 | + * 持有人放弃已提交或返工中的任务:只要还没结算,都可以释放回看板。 | ||
| 333 | + * @type {Handler} | ||
| 334 | + */ | ||
| 335 | +function holderRelease({ task }, _action, actor, now) { | ||
| 336 | + if (!isHolder(task, actor)) return notAllowed('Only the current holder can release.') | ||
| 337 | + return released(task, 'released', now) | ||
| 338 | +} | ||
| 339 | + | ||
| 315 | /** @type {Handler} */ | 340 | /** @type {Handler} */ |
| 316 | function openDispute({ task }, _action, actor, now) { | 341 | function openDispute({ task }, _action, actor, now) { |
| 317 | if (actor.kind !== 'agent') return notAllowed('Only the publisher or a taker can open a dispute.') | 342 | 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) { | @@ -381,7 +406,7 @@ function released(task, reason, now) { | ||
| 381 | } | 406 | } |
| 382 | 407 | ||
| 383 | /** | 408 | /** |
| 384 | - * 任务回到 open。若此时已过截止,按原先给的工期(created_at → deadline_at)从现在起重新计时, | 409 | + * 任务回到 open(未结算前,持有人在任何状态都可以放弃)。若此时已过截止,按原先给的工期(created_at → deadline_at)从现在起重新计时, |
| 385 | * 否则任务既接不了也不会过期,会卡死。 | 410 | * 否则任务既接不了也不会过期,会卡死。 |
| 386 | * @param {Task} task | 411 | * @param {Task} task |
| 387 | * @param {Date} now | 412 | * @param {Date} now |
| @@ -389,7 +414,8 @@ function released(task, reason, now) { | @@ -389,7 +414,8 @@ function released(task, reason, now) { | ||
| 389 | */ | 414 | */ |
| 390 | function reopen(task, now) { | 415 | function reopen(task, now) { |
| 391 | const deadline = now < task.deadline_at ? task.deadline_at : new Date(now.getTime() + (task.deadline_at.getTime() - task.created_at.getTime())) | 416 | const deadline = now < task.deadline_at ? task.deadline_at : new Date(now.getTime() + (task.deadline_at.getTime() - task.created_at.getTime())) |
| 392 | - return { ...task, status: 'open', deadline_at: deadline, holder_agent_id: null, next_checkpoint_due_at: null, missed_checkpoints: 0 } | 417 | + // 新接手的人从零开始计返工次数。 |
| 418 | + 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 } | ||
| 393 | } | 419 | } |
| 394 | 420 | ||
| 395 | /** | 421 | /** |
packages/voko-exchange/test/split-relay.test.js
| @@ -42,6 +42,13 @@ describe('SM-02 splitRelayPayout', () => { | @@ -42,6 +42,13 @@ describe('SM-02 splitRelayPayout', () => { | ||
| 42 | expect(splitRelayPayout(2, holds, 'b')).toEqual([{ agent_id: 'b', amount: 2, kind: 'winner' }]) | 42 | expect(splitRelayPayout(2, holds, 'b')).toEqual([{ agent_id: 'b', amount: 2, kind: 'winner' }]) |
| 43 | }) | 43 | }) |
| 44 | 44 | ||
| 45 | + it('提交后又放弃的前任也参与平分', () => { | ||
| 46 | + expect(splitRelayPayout(100, [hold('a', 'submitted', true), hold('b', 'submitted', true)], 'b')).toEqual([ | ||
| 47 | + { agent_id: 'b', amount: 50, kind: 'winner' }, | ||
| 48 | + { agent_id: 'a', amount: 50, kind: 'relay_share' }, | ||
| 49 | + ]) | ||
| 50 | + }) | ||
| 51 | + | ||
| 45 | it('全部结束原因都被处理;未知原因直接 throw', () => { | 52 | it('全部结束原因都被处理;未知原因直接 throw', () => { |
| 46 | for (const reason of ['submitted', 'released', 'timeout', 'transferred', null]) expect(() => splitRelayPayout(10, [hold('a', reason, true)], 'b')).not.toThrow() | 53 | for (const reason of ['submitted', 'released', 'timeout', 'transferred', null]) expect(() => splitRelayPayout(10, [hold('a', reason, true)], 'b')).not.toThrow() |
| 47 | expect(() => splitRelayPayout(10, [hold('a', 'vanished', true)], 'b')).toThrow(/Unhandled end_reason: vanished/) | 54 | expect(() => splitRelayPayout(10, [hold('a', 'vanished', true)], 'b')).toThrow(/Unhandled end_reason: vanished/) |
packages/voko-exchange/test/transition.test.js
| @@ -44,7 +44,7 @@ function stateFor(status, template = TEMPLATE_OF[status] ?? 'piecework', overrid | @@ -44,7 +44,7 @@ function stateFor(status, template = TEMPLATE_OF[status] ?? 'piecework', overrid | ||
| 44 | updated_at: null, | 44 | updated_at: null, |
| 45 | ...overrides, | 45 | ...overrides, |
| 46 | }) | 46 | }) |
| 47 | - return { task, guild, publisher_owner_user_id: 'u_pub' } | 47 | + return { task, guild, publisher_owner_user_id: 'u_pub', entry_count: 1 } |
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | const TEMPLATE_OF = { in_contest: 'contest', reviewing: 'contest' } | 50 | const TEMPLATE_OF = { in_contest: 'contest', reviewing: 'contest' } |
| @@ -84,11 +84,11 @@ const EXPECTED = { | @@ -84,11 +84,11 @@ const EXPECTED = { | ||
| 84 | pending_approval: { approve: 'open', reject_approval: 'cancelled' }, | 84 | pending_approval: { approve: 'open', reject_approval: 'cancelled' }, |
| 85 | open: { claim: 'claimed', enter: 'in_contest', cancel: 'cancelled', open_expire: 'cancelled', edit: 'open' }, | 85 | open: { claim: 'claimed', enter: 'in_contest', cancel: 'cancelled', open_expire: 'cancelled', edit: 'open' }, |
| 86 | claimed: { checkpoint: 'claimed', checkpoint_miss: 'claimed', release: 'open', hold_timeout: 'open', transfer_accept: 'claimed', submit: 'submitted' }, | 86 | claimed: { checkpoint: 'claimed', checkpoint_miss: 'claimed', release: 'open', hold_timeout: 'open', transfer_accept: 'claimed', submit: 'submitted' }, |
| 87 | - in_contest: { enter: 'in_contest', submit: 'in_contest', contest_close: 'reviewing' }, | ||
| 88 | - submitted: { accept: 'settled', reject: 'rework', review_auto_accept: 'settled', dispute: 'disputed' }, | 87 | + in_contest: { enter: 'in_contest', submit: 'in_contest', release: 'open', contest_close: 'reviewing' }, |
| 88 | + submitted: { accept: 'settled', reject: 'rework', review_auto_accept: 'settled', dispute: 'disputed', release: 'open' }, | ||
| 89 | reviewing: { select_winner: 'settled', review_auto_accept: 'settled', dispute: 'disputed' }, | 89 | reviewing: { select_winner: 'settled', review_auto_accept: 'settled', dispute: 'disputed' }, |
| 90 | - rework: { submit: 'submitted', dispute: 'disputed' }, | ||
| 91 | - disputed: { resolve: 'settled' }, | 90 | + rework: { submit: 'submitted', dispute: 'disputed', release: 'open' }, |
| 91 | + disputed: { resolve: 'settled', release: 'open' }, | ||
| 92 | settled: {}, | 92 | settled: {}, |
| 93 | cancelled: {}, | 93 | cancelled: {}, |
| 94 | refunded: {}, | 94 | refunded: {}, |
| @@ -109,7 +109,7 @@ describe('SM-01 transition():11 个状态 × 全部动作', () => { | @@ -109,7 +109,7 @@ describe('SM-01 transition():11 个状态 × 全部动作', () => { | ||
| 109 | for (const type of TASK_ACTIONS) { | 109 | for (const type of TASK_ACTIONS) { |
| 110 | const expected = EXPECTED[status][type] | 110 | const expected = EXPECTED[status][type] |
| 111 | it(`${status} × ${type} → ${expected ?? 'state_conflict'}`, () => { | 111 | it(`${status} × ${type} → ${expected ?? 'state_conflict'}`, () => { |
| 112 | - const actor = status === 'in_contest' && type === 'submit' ? entrant : ACTOR_OF[type] | 112 | + const actor = status === 'in_contest' && (type === 'submit' || type === 'release') ? entrant : ACTOR_OF[type] |
| 113 | // open 的 enter 只对擂台赛成立;计件活 enter 的规则错误在下面单独断言。 | 113 | // open 的 enter 只对擂台赛成立;计件活 enter 的规则错误在下面单独断言。 |
| 114 | const template = status === 'open' && type === 'enter' ? 'contest' : undefined | 114 | const template = status === 'open' && type === 'enter' ? 'contest' : undefined |
| 115 | const result = run(stateFor(status, template), type, actor) | 115 | const result = run(stateFor(status, template), type, actor) |
| @@ -180,6 +180,22 @@ describe('SM-01 副作用与平台规则', () => { | @@ -180,6 +180,22 @@ describe('SM-01 副作用与平台规则', () => { | ||
| 180 | expect(effectTypes(result)).toEqual(['hold_end', 'task.released', 'task.released']) | 180 | expect(effectTypes(result)).toEqual(['hold_end', 'task.released', 'task.released']) |
| 181 | }) | 181 | }) |
| 182 | 182 | ||
| 183 | + it('未结算前持有人在任何状态都能放弃:返工次数清零、验收窗口清空;结算后不行', () => { | ||
| 184 | + for (const status of ['claimed', 'rework', 'submitted', 'disputed']) { | ||
| 185 | + const result = run(stateFor(status, 'piecework', { deadline_at: FUTURE, rework_count: status === 'claimed' ? 0 : 2 }), 'release') | ||
| 186 | + expect(result.task, status).toMatchObject({ status: 'open', holder_agent_id: null, rework_count: 0, review_due_at: null }) | ||
| 187 | + } | ||
| 188 | + expect(effectTypes(run(stateFor('disputed', 'piecework', { deadline_at: FUTURE }), 'release'))).toEqual(['dispute_close', 'hold_end', 'task.released', 'task.released']) | ||
| 189 | + for (const status of ['settled', 'cancelled', 'refunded']) expect(run(stateFor(status), 'release')).toMatchObject({ ok: false, code: 'state_conflict' }) | ||
| 190 | + }) | ||
| 191 | + | ||
| 192 | + it('擂台赛退赛:还有别人时仍在比赛中,最后一人退出回到 open', () => { | ||
| 193 | + const lastOne = run(stateFor('in_contest'), 'release', entrant) | ||
| 194 | + expect(lastOne).toMatchObject({ ok: true, task: { status: 'open' }, effects: [{ type: 'entry_remove', agent_id: 'ag_entrant' }] }) | ||
| 195 | + expect(run({ ...stateFor('in_contest'), entry_count: 3 }, 'release', entrant).task.status).toBe('in_contest') | ||
| 196 | + expect(run(stateFor('in_contest'), 'release', taker)).toMatchObject({ ok: false, code: 'forbidden' }) | ||
| 197 | + }) | ||
| 198 | + | ||
| 183 | it('未到期的 worker 动作是过时 job,返回 state_conflict', () => { | 199 | it('未到期的 worker 动作是过时 job,返回 state_conflict', () => { |
| 184 | expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'open_expire')).toMatchObject({ ok: false, code: 'state_conflict' }) | 200 | expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'open_expire')).toMatchObject({ ok: false, code: 'state_conflict' }) |
| 185 | expect(run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'hold_timeout')).toMatchObject({ ok: false, code: 'state_conflict' }) | 201 | expect(run(stateFor('claimed', 'piecework', { deadline_at: FUTURE }), 'hold_timeout')).toMatchObject({ ok: false, code: 'state_conflict' }) |
tools/voko-company-agent/agent.mjs
| @@ -14,7 +14,7 @@ | @@ -14,7 +14,7 @@ | ||
| 14 | import { createHash } from 'node:crypto' | 14 | import { createHash } from 'node:crypto' |
| 15 | import { readFileSync } from 'node:fs' | 15 | import { readFileSync } from 'node:fs' |
| 16 | 16 | ||
| 17 | -const SUPPORTED_SKILL_VERSION = '0.2.1' | 17 | +const SUPPORTED_SKILL_VERSION = '0.3.0' |
| 18 | const config = { | 18 | const config = { |
| 19 | api: required('VOKO_API_BASE').replace(/\/$/, ''), | 19 | api: required('VOKO_API_BASE').replace(/\/$/, ''), |
| 20 | key: required('VOKO_API_KEY'), | 20 | key: required('VOKO_API_KEY'), |
| @@ -71,6 +71,24 @@ async function voko(method, path, opts = {}) { | @@ -71,6 +71,24 @@ async function voko(method, path, opts = {}) { | ||
| 71 | throw error | 71 | throw error |
| 72 | } | 72 | } |
| 73 | 73 | ||
| 74 | +const TEXT_FILE = /\.(md|markdown|txt|csv|json|html?|xml|ya?ml)$/i | ||
| 75 | +const MAX_READ_BYTES = 64 * 1024 | ||
| 76 | + | ||
| 77 | +/** | ||
| 78 | + * 把一份提交的交付物整理成模型能读的样子:小的文本文件下载后附上正文,其余只给文件名与大小;外链原样给出。 | ||
| 79 | + * @param {Array<Record<string, any>>} artifacts | ||
| 80 | + */ | ||
| 81 | +async function readArtifacts(artifacts) { | ||
| 82 | + return Promise.all(artifacts.map(async (artifact) => { | ||
| 83 | + if (artifact.type !== 'file') return artifact | ||
| 84 | + const described = { type: 'file', filename: artifact.filename, size_bytes: artifact.size_bytes, note: artifact.note } | ||
| 85 | + if (!TEXT_FILE.test(artifact.filename) || artifact.size_bytes > MAX_READ_BYTES) return described | ||
| 86 | + const response = await fetch(`${config.api}/files/${artifact.file_id}`, { headers: { authorization: `Bearer ${config.key}` } }) | ||
| 87 | + if (!response.ok) throw new Error(`GET /files/${artifact.file_id} → ${response.status}`) | ||
| 88 | + return { ...described, content: await response.text() } | ||
| 89 | + })) | ||
| 90 | +} | ||
| 91 | + | ||
| 74 | /** | 92 | /** |
| 75 | * 让大模型按给定说明处理一份数据,返回解析后的 JSON。 | 93 | * 让大模型按给定说明处理一份数据,返回解析后的 JSON。 |
| 76 | * @param {string} instructions | 94 | * @param {string} instructions |
| @@ -116,9 +134,9 @@ async function reviewPiecework(task) { | @@ -116,9 +134,9 @@ async function reviewPiecework(task) { | ||
| 116 | const submission = task.submissions.at(-1) | 134 | const submission = task.submissions.at(-1) |
| 117 | if (submission === undefined) return | 135 | if (submission === undefined) return |
| 118 | const verdict = await askModel( | 136 | const verdict = await askModel( |
| 119 | - '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. ' | 137 | + '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. ' |
| 120 | + 'Reply as {"checks":[{"id":"<acceptance item id>","pass":true|false,"reason":"one specific sentence"}]} with exactly one entry per acceptance item.', | 138 | + 'Reply as {"checks":[{"id":"<acceptance item id>","pass":true|false,"reason":"one specific sentence"}]} with exactly one entry per acceptance item.', |
| 121 | - { task: { title: task.title, spec: task.spec }, acceptance: task.acceptance, submission: { summary: submission.summary, artifacts: submission.artifacts } }, | 139 | + { task: { title: task.title, spec: task.spec }, acceptance: task.acceptance, submission: { summary: submission.summary, artifacts: await readArtifacts(submission.artifacts) } }, |
| 122 | ) | 140 | ) |
| 123 | const checks = validChecks(verdict.checks, task.acceptance) | 141 | const checks = validChecks(verdict.checks, task.acceptance) |
| 124 | if (checks === null) return log('skip review: model output did not cover the checklist', { task_id: task.id }) | 142 | 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) { | @@ -134,7 +152,7 @@ async function reviewContest(task) { | ||
| 134 | const verdict = await askModel( | 152 | const verdict = await askModel( |
| 135 | '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. ' | 153 | '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. ' |
| 136 | + 'Reply as {"submissions":[{"submission_id":"...","checks":[{"id":"<acceptance item id>","pass":true|false,"reason":"..."}]}],"winner_submission_id":"..."|null}.', | 154 | + 'Reply as {"submissions":[{"submission_id":"...","checks":[{"id":"<acceptance item id>","pass":true|false,"reason":"..."}]}],"winner_submission_id":"..."|null}.', |
| 137 | - { 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 })) }, | 155 | + { 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) }))) }, |
| 138 | ) | 156 | ) |
| 139 | const winner = Array.isArray(verdict.submissions) ? verdict.submissions.find((item) => item && item.submission_id === verdict.winner_submission_id) : undefined | 157 | const winner = Array.isArray(verdict.submissions) ? verdict.submissions.find((item) => item && item.submission_id === verdict.winner_submission_id) : undefined |
| 140 | const checks = winner === undefined ? null : validChecks(winner.checks, task.acceptance) | 158 | const checks = winner === undefined ? null : validChecks(winner.checks, task.acceptance) |
| @@ -193,7 +211,7 @@ async function publishIfNeeded(home) { | @@ -193,7 +211,7 @@ async function publishIfNeeded(home) { | ||
| 193 | const plan = await askModel( | 211 | const plan = await askModel( |
| 194 | `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. ` | 212 | `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. ` |
| 195 | + `Each reward is a whole number of points between 1 and ${rewardCap}; the total must not exceed ${home.wallet.available}. ` | 213 | + `Each reward is a whole number of points between 1 and ${rewardCap}; the total must not exceed ${home.wallet.available}. ` |
| 196 | - + '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. ' | 214 | + + '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. ' |
| 197 | + 'Reply as {"tasks":[{"title":"...","spec":"...","template":"piecework"|"contest","reward":40,"deadline_hours":48,"acceptance":["...","..."]}]} with 1 to 5 acceptance items per task.', | 215 | + 'Reply as {"tasks":[{"title":"...","spec":"...","template":"piecework"|"contest","reward":40,"deadline_hours":48,"acceptance":["...","..."]}]} with 1 to 5 acceptance items per task.', |
| 198 | { brief: config.brief, existing_tasks: mine.map((task) => task.title), weekly_goal: guild.weekly_goal, weekly_progress: guild.weekly_progress }, | 216 | { brief: config.brief, existing_tasks: mine.map((task) => task.title), weekly_goal: guild.weekly_goal, weekly_progress: guild.weekly_progress }, |
| 199 | ) | 217 | ) |
tools/voko-company-agent/brief.example.md
| @@ -9,5 +9,5 @@ This week we want: | @@ -9,5 +9,5 @@ This week we want: | ||
| 9 | 9 | ||
| 10 | Guidelines for tasks: | 10 | Guidelines for tasks: |
| 11 | - one clear deliverable per task, finishable by one agent within two days | 11 | - one clear deliverable per task, finishable by one agent within two days |
| 12 | -- the deliverable is a link to a document; say so in the acceptance checklist | 12 | +- the deliverable is a file uploaded to the platform (for example a Markdown document) |
| 13 | - typical rewards: 20–60 points; never above 100 | 13 | - typical rewards: 20–60 points; never above 100 |