diff --git a/src/api/server.ts b/src/api/server.ts
index 58bb210..73e8c2a 100644
--- a/src/api/server.ts
+++ b/src/api/server.ts
@@ -12,10 +12,10 @@ import { acceptAndMerge } from '../executor/exec-merge.js';
import { createWorktree, git } from '../executor/worktree.js';
import { resolveLogo, LOGO_MIME } from './logo.js';
import { readTranscript, TranscriptError } from '../executor/transcript.js';
-import { createReadStream, createWriteStream, mkdirSync, readFileSync, statSync } from 'node:fs';
+import { createReadStream, createWriteStream, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { transcriptDir } from '../executor/cc.js';
import { homedir } from 'node:os';
-import { join, basename } from 'node:path';
+import { join, basename, resolve, sep } from 'node:path';
import { pipeline } from 'node:stream/promises';
import multipart from '@fastify/multipart';
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
@@ -356,6 +356,48 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
return { attachments: store.addAttachments(id, saved) };
});
+ // 附件读取/预览/下载:默认 inline(图片可
预览);?download=1 触发下载。
+ // 标识符为磁盘文件名 :name(= basename(att.path)),只服务元数据里登记过的附件。
+ app.get('/api/tasks/:id/attachments/:name', (req, reply) => {
+ const { id, name } = req.params as { id: string; name: string };
+ const task = store.getTask(id);
+ if (!task) return reply.code(404).send({ error: `任务不存在: ${id}` });
+ const att = (task.attachments ?? []).find((a) => basename(a.path) === name);
+ if (!att) return reply.code(404).send({ error: `附件不存在: ${name}` });
+
+ const abs = resolve(join(dataDir(), att.path));
+ // 纵深防御:解析后必须仍在该任务 attachments 目录内
+ const baseDir = resolve(join(dataDir(), 'tasks', id, 'attachments'));
+ if (abs !== baseDir && !abs.startsWith(baseDir + sep))
+ return reply.code(400).send({ error: '附件路径越界' });
+ let st;
+ try { st = statSync(abs); } catch { return reply.code(404).send({ error: '附件文件已丢失' }); }
+
+ const dl = (req.query as { download?: string }).download;
+ const fallbackName = att.name || name;
+ reply
+ .header('cache-control', 'private, max-age=300')
+ .header('content-length', String(st.size))
+ .header('content-disposition',
+ `${dl ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(fallbackName)}`)
+ .type(att.type || 'application/octet-stream');
+ return reply.send(createReadStream(abs));
+ });
+
+ // 删除单个附件:先删元数据(拿到 removed),再尽力删磁盘文件
+ app.delete('/api/tasks/:id/attachments/:name', (req, reply) => {
+ const { id, name } = req.params as { id: string; name: string };
+ if (!store.getTask(id)) return reply.code(404).send({ error: `任务不存在: ${id}` });
+ const { attachments, removed } = store.removeAttachment(id, name);
+ if (!removed) return reply.code(404).send({ error: `附件不存在: ${name}` });
+ const abs = resolve(join(dataDir(), removed.path));
+ const baseDir = resolve(join(dataDir(), 'tasks', id, 'attachments'));
+ if (abs === baseDir || abs.startsWith(baseDir + sep)) {
+ try { rmSync(abs, { force: true }); } catch { /* 磁盘文件已不在,忽略 */ }
+ }
+ return { attachments };
+ });
+
// 人工接管:准备(或复用)任务 worktree,返回用户在自己终端起交互式 claude 的命令。
// 适用卡住/需人工的任务——人接手手动改。不在 daemon 内起交互会话(无 TTY)。
app.post('/api/tasks/:id/takeover', async (req, reply) => {
diff --git a/src/store/store.ts b/src/store/store.ts
index a779637..284eb0a 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -16,6 +16,7 @@ import { computeCost, addUsage, EMPTY_USAGE, type UsageTokens } from '../model/p
import {
type TaskStatus, type GateKind, canTransition, initialNextStatus, gateOf, STATUS_LABEL,
} from '../model/status.js';
+import { basename } from 'node:path';
const now = (): string => new Date().toISOString();
@@ -419,12 +420,28 @@ export class Store {
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];
+ // 按 path 去重(later-wins):path 唯一对应一份磁盘文件,重传同名文件只保留最新一条元数据。
+ const byPath = new Map();
+ for (const a of [...cur, ...items]) byPath.set(a.path, a);
+ const next = [...byPath.values()];
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;
}
+ /** 按磁盘文件名(basename(path))移除一条附件元数据。返回剩余列表与被移除项(不存在则 removed=null)。 */
+ removeAttachment(taskId: string, name: string): { attachments: Attachment[]; removed: Attachment | null } {
+ const t = this.getTaskRow(taskId);
+ if (!t) throw new StoreError(`任务不存在: ${taskId}`);
+ const cur: Attachment[] = t.attachments ? JSON.parse(t.attachments) as Attachment[] : [];
+ const removed = cur.find((a) => basename(a.path) === name) ?? null;
+ if (!removed) return { attachments: cur, removed: null };
+ const next = cur.filter((a) => a !== removed);
+ 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 { attachments: next, removed };
+ }
+
private getTaskRow(taskId: string): TaskRow | undefined {
return this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId) as TaskRow | undefined;
}
diff --git a/test/attachments.test.ts b/test/attachments.test.ts
new file mode 100644
index 0000000..4a898bd
--- /dev/null
+++ b/test/attachments.test.ts
@@ -0,0 +1,216 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Store } from '../src/store/index.js';
+import { buildServer } from '../src/api/server.js';
+import type { Attachment } from '../src/model/types.js';
+
+/** 临时数据目录(充当 MAESTRO_DATA_DIR)。 */
+function tmpData(): string {
+ return mkdtempSync(join(tmpdir(), 'maestro-attach-'));
+}
+
+/** 在 store 里建项目 + 任务,返回 {store, taskId}。 */
+function freshTask(): { store: Store; taskId: string } {
+ const store = new Store(':memory:');
+ const p = store.createProject({ name: 'a', repoPath: '/tmp/a-' + Math.random() });
+ const t = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
+ return { store, taskId: t.id };
+}
+
+/** 把一份附件文件落盘到 /tasks//attachments/,并返回元数据。 */
+function placeFile(dir: string, taskId: string, name: string, mime: string, body: Buffer | string): Attachment {
+ const attDir = join(dir, 'tasks', taskId, 'attachments');
+ mkdirSync(attDir, { recursive: true });
+ writeFileSync(join(attDir, name), body);
+ return { name, type: mime, path: `tasks/${taskId}/attachments/${name}` };
+}
+
+// ───────────────────────── Store 层 ─────────────────────────
+
+test('addAttachments:按 path 去重(later-wins)', () => {
+ const { store, taskId } = freshTask();
+ store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', path: `tasks/${taskId}/attachments/a.png` }]);
+ // 重传同一 path(safeName 相同)→ 元数据只保留一条,且取最新 name/type
+ const next = store.addAttachments(taskId, [
+ { name: 'a-renamed.png', type: 'image/jpeg', path: `tasks/${taskId}/attachments/a.png` },
+ { name: 'b.pdf', type: 'application/pdf', path: `tasks/${taskId}/attachments/b.pdf` },
+ ]);
+ assert.equal(next.length, 2);
+ const a = next.find((x) => x.path.endsWith('/a.png'));
+ assert.equal(a?.name, 'a-renamed.png');
+ assert.equal(a?.type, 'image/jpeg');
+ // 插入顺序保持:a 在前、b 在后
+ assert.deepEqual(next.map((x) => x.path.split('/').pop()), ['a.png', 'b.pdf']);
+ store.close();
+});
+
+test('removeAttachment:按磁盘文件名移除,返回 removed;不存在则 removed=null', () => {
+ const { store, taskId } = freshTask();
+ store.addAttachments(taskId, [
+ { name: 'keep.png', type: 'image/png', path: `tasks/${taskId}/attachments/keep.png` },
+ { name: 'gone.pdf', type: 'application/pdf', path: `tasks/${taskId}/attachments/gone.pdf` },
+ ]);
+ const r1 = store.removeAttachment(taskId, 'gone.pdf');
+ assert.equal(r1.removed?.path, `tasks/${taskId}/attachments/gone.pdf`);
+ assert.deepEqual(r1.attachments.map((x) => x.path.split('/').pop()), ['keep.png']);
+ // 再删不存在的名字 → removed=null,列表不变
+ const r2 = store.removeAttachment(taskId, 'nope.png');
+ assert.equal(r2.removed, null);
+ assert.equal(r2.attachments.length, 1);
+ store.close();
+});
+
+test('removeAttachment / addAttachments:任务不存在抛错', () => {
+ const store = new Store(':memory:');
+ assert.throws(() => store.removeAttachment('tsk_nope', 'x'));
+ assert.throws(() => store.addAttachments('tsk_nope', []));
+ store.close();
+});
+
+// ───────────────────────── API 层(Fastify inject)─────────────────────────
+
+test('GET 附件:默认 inline,content-type/length 正确', async () => {
+ const dir = tmpData();
+ process.env.MAESTRO_DATA_DIR = dir;
+ const { store, taskId } = freshTask();
+ const png = Buffer.from('\x89PNG\r\n\x1a\n-fake-image-bytes', 'binary');
+ store.addAttachments(taskId, [placeFile(dir, taskId, 'pic.png', 'image/png', png)]);
+
+ const app = buildServer({ store });
+ const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/pic.png` });
+ assert.equal(res.statusCode, 200);
+ assert.match(res.headers['content-type'] as string, /image\/png/);
+ assert.equal(res.headers['content-length'], String(png.length));
+ assert.match(res.headers['content-disposition'] as string, /^inline; filename\*=UTF-8''pic\.png/);
+ await app.close();
+ store.close();
+});
+
+test('GET 附件:?download=1 → attachment + RFC5987 文件名(中文不乱码)', async () => {
+ const dir = tmpData();
+ process.env.MAESTRO_DATA_DIR = dir;
+ const { store, taskId } = freshTask();
+ // 磁盘安全名为 ascii,但原始 name 含中文
+ store.addAttachments(taskId, [{
+ name: '设计稿.png', type: 'image/png', path: `tasks/${taskId}/attachments/paste-1.png`,
+ }]);
+ placeFile(dir, taskId, 'paste-1.png', 'image/png', 'data');
+
+ const app = buildServer({ store });
+ const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/paste-1.png?download=1` });
+ assert.equal(res.statusCode, 200);
+ const cd = res.headers['content-disposition'] as string;
+ assert.match(cd, /^attachment; filename\*=UTF-8''/);
+ assert.match(cd, new RegExp(encodeURIComponent('设计稿.png')));
+ await app.close();
+ store.close();
+});
+
+test('DELETE 附件:剔除元数据 + 删磁盘文件;之后 GET 404', async () => {
+ const dir = tmpData();
+ process.env.MAESTRO_DATA_DIR = dir;
+ const { store, taskId } = freshTask();
+ store.addAttachments(taskId, [placeFile(dir, taskId, 'doc.pdf', 'application/pdf', 'pdfdata')]);
+ const absFile = join(dir, 'tasks', taskId, 'attachments', 'doc.pdf');
+ assert.ok(existsSync(absFile));
+
+ const app = buildServer({ store });
+ const del = await app.inject({ method: 'DELETE', url: `/api/tasks/${taskId}/attachments/doc.pdf` });
+ assert.equal(del.statusCode, 200);
+ assert.deepEqual(del.json().attachments, []);
+ assert.ok(!existsSync(absFile), '磁盘文件应被删除');
+
+ const get = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/doc.pdf` });
+ assert.equal(get.statusCode, 404);
+ await app.close();
+ store.close();
+});
+
+test('GET/DELETE:任务不存在 → 404', async () => {
+ const store = new Store(':memory:');
+ const app = buildServer({ store });
+ const g = await app.inject({ method: 'GET', url: `/api/tasks/tsk_nope/attachments/x.png` });
+ assert.equal(g.statusCode, 404);
+ const d = await app.inject({ method: 'DELETE', url: `/api/tasks/tsk_nope/attachments/x.png` });
+ assert.equal(d.statusCode, 404);
+ await app.close();
+ store.close();
+});
+
+test('GET:未登记的附件名 → 404(不返回目录外/任意文件)', async () => {
+ const dir = tmpData();
+ process.env.MAESTRO_DATA_DIR = dir;
+ const { store, taskId } = freshTask();
+ placeFile(dir, taskId, 'secret.png', 'image/png', 'x'); // 落盘但不登记元数据
+ const app = buildServer({ store });
+ const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/secret.png` });
+ assert.equal(res.statusCode, 404);
+ await app.close();
+ store.close();
+});
+
+test('GET:元数据在但磁盘文件丢失 → 404「附件文件已丢失」', async () => {
+ const dir = tmpData();
+ process.env.MAESTRO_DATA_DIR = dir;
+ const { store, taskId } = freshTask();
+ // 只写元数据,不落盘
+ store.addAttachments(taskId, [{ name: 'ghost.png', type: 'image/png', path: `tasks/${taskId}/attachments/ghost.png` }]);
+ const app = buildServer({ store });
+ const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/ghost.png` });
+ assert.equal(res.statusCode, 404);
+ assert.match(res.json().error, /丢失/);
+ await app.close();
+ store.close();
+});
+
+test('DELETE:元数据在但磁盘文件已被外部删除 → 仍成功清元数据', async () => {
+ const dir = tmpData();
+ process.env.MAESTRO_DATA_DIR = dir;
+ const { store, taskId } = freshTask();
+ store.addAttachments(taskId, [{ name: 'ghost.png', type: 'image/png', path: `tasks/${taskId}/attachments/ghost.png` }]);
+ const app = buildServer({ store });
+ const res = await app.inject({ method: 'DELETE', url: `/api/tasks/${taskId}/attachments/ghost.png` });
+ assert.equal(res.statusCode, 200);
+ assert.deepEqual(res.json().attachments, []);
+ await app.close();
+ store.close();
+});
+
+test('上传→去重端到端:同名文件连传两次,元数据只一条', async () => {
+ const dir = tmpData();
+ process.env.MAESTRO_DATA_DIR = dir;
+ const { store, taskId } = freshTask();
+ const app = buildServer({ store });
+
+ function multipart(filename: string, content: string): { payload: Buffer; headers: Record } {
+ const boundary = '----maestrotest';
+ const body =
+ `--${boundary}\r\n` +
+ `Content-Disposition: form-data; name="files"; filename="${filename}"\r\n` +
+ `Content-Type: image/png\r\n\r\n` +
+ content + `\r\n` +
+ `--${boundary}--\r\n`;
+ return { payload: Buffer.from(body), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
+ }
+
+ const m1 = multipart('shot.png', 'aaa');
+ const up1 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m1.payload, headers: m1.headers });
+ assert.equal(up1.statusCode, 200);
+ assert.equal(up1.json().attachments.length, 1);
+
+ const m2 = multipart('shot.png', 'bbb');
+ const up2 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m2.payload, headers: m2.headers });
+ assert.equal(up2.statusCode, 200);
+ // 同名 safeName → path 相同 → 去重为一条
+ const atts = up2.json().attachments;
+ assert.equal(atts.length, 1, '同名重传应去重为一条');
+
+ // 通过 GET /api/tasks/:id 复核
+ const t = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}` });
+ assert.equal(t.json().attachments.length, 1);
+ await app.close();
+ store.close();
+});