feat: 任务附件上传(图片/文件随任务提交)

B-① 附件上传(⑧ ★ POST /api/tasks/:id/attachments):

后端:
- 加 @fastify/multipart;schema/db.ts tasks.attachments 列(幂等迁移)
- types.Attachment{name,type,path} + Task.attachments;mappers 映射
- store.addAttachments(追加元数据 + 广播 task.updated)
- POST /api/tasks/:id/attachments:multipart files[](单文件≤25MB/单次≤10),
  落盘 <data>/tasks/<id>/attachments/,文件名消毒,返回全部附件

前端:
- api.uploadAttachments(FormData multipart)
- 新建任务表单加文件选择 + 已选列表;创建任务后自动上传附件

验证:typecheck 干净;206 测试通过;前端 build 通过。
(端到端需重启 daemon 加载新端点——与后续 SSE/takeover 一并重启。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-24 16:58:14 +08:00
parent 6c9b05e575
commit 9b17d3e24f
11 changed files with 157 additions and 6 deletions
+30 -1
View File
@@ -13,9 +13,18 @@ import { git, removeWorktree } from '../executor/worktree.js';
import { cleanupTaskRunArtifacts } from '../executor/cleanup.js';
import { resolveLogo, LOGO_MIME } from './logo.js';
import { readTranscript, TranscriptError } from '../executor/transcript.js';
import { createReadStream } from 'node:fs';
import { createReadStream, createWriteStream, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, basename } from 'node:path';
import { pipeline } from 'node:stream/promises';
import multipart from '@fastify/multipart';
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
/** 数据根目录(与 protocol/worktree 同约定):<MAESTRO_DATA_DIR 或 ~/.maestro> */
function dataDir(): string {
return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
}
/** Project 出参:附加 hasTodoJson<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
function projectOut(p: Project): Project & { hasTodoJson: boolean } {
return { ...p, hasTodoJson: hasTodoJson(p.repoPath) };
@@ -39,6 +48,9 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
const classify = opts.classify ?? ((title, description, cwd) => classifyComplexity(title, description, { cwd }));
const app = Fastify({ logger: opts.logger ?? false });
// 附件上传:multipart/form-data(单文件 ≤25MB,单次 ≤10 个)
app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024, files: 10 } });
// Store 错误 → 400(业务校验),其余 → 500
app.setErrorHandler((err, _req, reply) => {
if (err instanceof StoreError) return reply.code(400).send({ error: err.message });
@@ -218,6 +230,23 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
return t;
});
// 附件上传(multipart files[]):落盘 <data>/tasks/<id>/attachments/,元数据写 tasks.attachments
app.post('/api/tasks/:id/attachments', async (req, reply) => {
const { id } = req.params as { id: string };
if (!store.getTask(id)) return reply.code(404).send({ error: `任务不存在: ${id}` });
const dir = join(dataDir(), 'tasks', id, 'attachments');
mkdirSync(dir, { recursive: true });
const saved: Array<{ name: string; type: string; path: string }> = [];
for await (const part of req.files()) {
const safe = basename(part.filename).replace(/[^\w.\-]/g, '_') || `file-${Date.now()}`;
await pipeline(part.file, createWriteStream(join(dir, safe)));
if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename} 超过 25MB 上限` });
saved.push({ name: part.filename, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` });
}
if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' });
return { attachments: store.addAttachments(id, saved) };
});
// 部分更新任务(title/priority/complexitycomplexity 重置逻辑在 Store.patchTask
app.patch('/api/tasks/:id', (req) => {
const { id } = req.params as { id: string };
+8
View File
@@ -83,10 +83,18 @@ export interface Task {
retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用)
nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领
lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示)
attachments?: Attachment[]; // 随任务提交的图片/文件(存 <data>/tasks/<id>/attachments/
createdAt: string;
updatedAt: string;
}
/** 任务附件:图片/文件随任务提交,落盘于 <MAESTRO_DATA_DIR>/tasks/<taskId>/attachments/ */
export interface Attachment {
name: string; // 原始文件名
type: string; // MIME 类型(如 image/png
path: string; // 相对 data 根的存储路径(tasks/<taskId>/attachments/<name>
}
export type RunKind = 'planner' | 'executor' | 'reviewer' | 'security';
export type RunStatus = 'started' | 'succeeded' | 'failed' | 'cancelled';
+1
View File
@@ -36,6 +36,7 @@ export function openDb(file: string): Database.Database {
ensureColumn(db, 'runs', 'cost_usd', 'cost_usd REAL'); // 该 run 折算成本 USD(按模型×token)
ensureColumn(db, 'projects', 'budget_usd', 'budget_usd REAL'); // 项目当期预算上限 USD(null=不限)
ensureColumn(db, 'projects', 'budget_period', "budget_period TEXT NOT NULL DEFAULT 'month'"); // 预算周期 day|month
ensureColumn(db, 'tasks', 'attachments', 'attachments TEXT'); // 附件 JSON [{name,type,path}](图片/文件随任务提交)
const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8');
db.exec(schema);
return db;
+2
View File
@@ -21,6 +21,7 @@ export interface TaskRow {
next_eligible_at: string | null;
created_at: string; updated_at: string;
source_ref: string | null;
attachments: string | null;
last_run_error?: string | null; // pendingApprovals 扩展字段(subquery
}
export interface ApprovalRow {
@@ -81,6 +82,7 @@ export function rowToTask(r: TaskRow, approvals: ApprovalRecord[] = []): Task {
assignee: r.assignee as Task['assignee'], retryBaseline: r.retry_baseline ?? 0,
nextEligibleAt: r.next_eligible_at ?? null,
lastRunError: r.last_run_error ?? null,
attachments: r.attachments ? (JSON.parse(r.attachments) as Task['attachments']) : undefined,
createdAt: r.created_at, updatedAt: r.updated_at,
};
}
+2 -1
View File
@@ -44,7 +44,8 @@ CREATE TABLE IF NOT EXISTS tasks (
next_eligible_at TEXT, -- 持久化退避:早于此时间不被领取(null=即刻可领)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
source_ref TEXT -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一
source_ref TEXT, -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一
attachments TEXT -- 附件 JSON [{name,type,path}](图片/文件随任务提交)
);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id, status);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
+13 -2
View File
@@ -4,7 +4,7 @@ import {
rowToProject, rowToTask, rowToApproval, rowToRun, rowToEvent,
type ProjectRow, type TaskRow, type ApprovalRow, type RunRow, type EventRow,
} from './mappers.js';
import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType } from '../model/types.js';
import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType, Attachment } from '../model/types.js';
import { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js';
import type { Complexity } from '../model/complexity.js';
import { rankByScore } from '../model/scoring.js';
@@ -277,7 +277,7 @@ export class Store {
title: input.title, complexity: input.complexity, status, priority: input.priority ?? 1,
deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null,
result: null, assignee: null, retry_baseline: 0, next_eligible_at: null,
created_at: now(), updated_at: now(), source_ref: null,
created_at: now(), updated_at: now(), source_ref: null, attachments: null,
};
this.db.prepare(
`INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,retry_baseline,created_at,updated_at,source_ref)
@@ -287,6 +287,17 @@ export class Store {
return rowToTask(row);
}
/** 追加任务附件(文件已落盘,这里只记元数据)。返回更新后的全部附件。 */
addAttachments(taskId: string, items: Attachment[]): Attachment[] {
const t = this.getTaskRow(taskId);
if (!t) throw new StoreError(`任务不存在: ${taskId}`);
const cur: Attachment[] = t.attachments ? JSON.parse(t.attachments) as Attachment[] : [];
const next = [...cur, ...items];
this.db.prepare(`UPDATE tasks SET attachments = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(next), now(), taskId);
this.emit(t.project_id, taskId, 'task.updated', { field: 'attachments', count: next.length });
return next;
}
private getTaskRow(taskId: string): TaskRow | undefined {
return this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId) as TaskRow | undefined;
}