Commit 7cac2f6868ec4dbcb9bbd42debb93385289248e1

Authored by yibo
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>
.gitignore
@@ -17,3 +17,5 @@ tmp/ @@ -17,3 +17,5 @@ tmp/
17 !.env.example 17 !.env.example
18 !.env.*.example 18 !.env.*.example
19 tmp-voko-server.log 19 tmp-voko-server.log
  20 +# 本地交付文件存储
  21 +apps/voko-server/data/
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 &#39;@drama/observability&#39; @@ -12,10 +12,11 @@ import { createLogger } from &#39;@drama/observability&#39;
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 &#39;@drama/db&#39; @@ -12,6 +13,7 @@ import { devMysqlUrl } from &#39;@drama/db&#39;
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([&#39;active&#39;, &#39;suspended&#39;]) }) @@ -31,7 +32,7 @@ const StatusBody = v.object({ status: v.picklist([&#39;active&#39;, &#39;suspended&#39;]) })
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 &#39;@voko/clearing&#39; @@ -12,6 +14,8 @@ import { verifyConservation } from &#39;@voko/clearing&#39;
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 &#39;./_lib.mjs&#39; @@ -13,7 +13,7 @@ import { runScenario } from &#39;./_lib.mjs&#39;
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(&#39;ca&#39;, async ({ db, check, call, admin, createOwner, createGuil @@ -24,12 +24,14 @@ await runScenario(&#39;ca&#39;, 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(&#39;f2&#39;, async ({ db, check, call, waitForEmail, createOwner, cre @@ -93,12 +93,29 @@ await runScenario(&#39;f2&#39;, 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(&#39;f3&#39;, async ({ db, check, call, admin, createOwner, createGuil @@ -107,6 +107,28 @@ await runScenario(&#39;f3&#39;, 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 &#39;./tasks.js&#39; @@ -12,6 +12,7 @@ export { createTasks } from &#39;./tasks.js&#39;
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 = &#39;vk_jobs&#39; @@ -42,9 +42,10 @@ const JOBS = &#39;vk_jobs&#39;
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(&#39;SM-02 splitRelayPayout&#39;, () =&gt; { @@ -42,6 +42,13 @@ describe(&#39;SM-02 splitRelayPayout&#39;, () =&gt; {
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] ?? &#39;piecework&#39;, overrid @@ -44,7 +44,7 @@ function stateFor(status, template = TEMPLATE_OF[status] ?? &#39;piecework&#39;, 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(&#39;SM-01 transition():11 个状态 × 全部动作&#39;, () =&gt; { @@ -109,7 +109,7 @@ describe(&#39;SM-01 transition():11 个状态 × 全部动作&#39;, () =&gt; {
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(&#39;SM-01 副作用与平台规则&#39;, () =&gt; { @@ -180,6 +180,22 @@ describe(&#39;SM-01 副作用与平台规则&#39;, () =&gt; {
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