merge: maestro/tsk_fTul9G4eutVn [tsk_fTul9G4eutVn]
This commit is contained in:
+39
-9
@@ -4,7 +4,7 @@ import { Store, StoreError, type ActiveRun, type PatchProjectInput, type PatchTa
|
||||
import type { Complexity } from '../model/complexity.js';
|
||||
import { isComplexity } from '../model/complexity.js';
|
||||
import type { TaskStatus } from '../model/status.js';
|
||||
import type { Project, Autonomy } from '../model/types.js';
|
||||
import type { Project, Autonomy, Attachment } from '../model/types.js';
|
||||
import { syncProject, hasTodoJson } from '../sync/todo-sync.js';
|
||||
import { resolvedExecutorModels } from '../executor/models.js';
|
||||
import { classifyComplexity, type ClassifierFn } from '../executor/classify.js';
|
||||
@@ -12,7 +12,8 @@ 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, rmSync, statSync } from 'node:fs';
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { transcriptDir } from '../executor/cc.js';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, basename, resolve, sep } from 'node:path';
|
||||
@@ -343,14 +344,40 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
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 }> = [];
|
||||
let seq = 0;
|
||||
const saved: Attachment[] = [];
|
||||
for await (const part of req.files()) {
|
||||
// 无名/无扩展名(如剪贴板粘贴 blob)→ 按 mimetype 生成安全文件名+扩展名
|
||||
const safe = safeAttachmentName(part.filename, part.mimetype, seq++);
|
||||
await pipeline(part.file, createWriteStream(join(dir, safe)));
|
||||
if (part.file.truncated) return reply.code(400).send({ error: `文件 ${part.filename || safe} 超过 25MB 上限` });
|
||||
saved.push({ name: part.filename || safe, type: part.mimetype, path: `tasks/${id}/attachments/${safe}` });
|
||||
// 内容寻址:先流式写临时文件并同步计算 sha256,再按 hash 重命名为最终磁盘名。
|
||||
// 磁盘名完全由内容 hash 决定(不再用文件名):相同内容天然单份、不同内容永不撞名,
|
||||
// 且十六进制 sha256 不含 `/`、`..`、脚本可控字符,从根上杜绝路径穿越/撞名覆盖。
|
||||
const ext = extFromMime(part.mimetype) || (hasExtension(part.filename ?? '')
|
||||
? (part.filename as string).slice((part.filename as string).lastIndexOf('.') + 1).replace(/[^A-Za-z0-9]/g, '')
|
||||
: '');
|
||||
const tmp = join(dir, `.tmp-${randomUUID()}`);
|
||||
const hash = createHash('sha256');
|
||||
let size = 0;
|
||||
try {
|
||||
await pipeline(part.file, async function* (src) {
|
||||
for await (const c of src) { hash.update(c as Buffer); size += (c as Buffer).length; yield c; }
|
||||
}, createWriteStream(tmp));
|
||||
} catch (e) {
|
||||
rmSync(tmp, { force: true });
|
||||
throw e;
|
||||
}
|
||||
// 超限:清掉临时文件,绝不留垃圾;@fastify/multipart 在超过 fileSize 时置 truncated。
|
||||
if (part.file.truncated) {
|
||||
rmSync(tmp, { force: true });
|
||||
return reply.code(400).send({ error: `文件 ${part.filename || 'attachment'} 超过 25MB 上限` });
|
||||
}
|
||||
const digest = hash.digest('hex');
|
||||
const finalName = ext ? `${digest}.${ext}` : digest;
|
||||
const finalAbs = join(dir, finalName);
|
||||
// 相同内容已存在 → 丢弃临时文件(真去重);否则原子 rename 同目录就位。
|
||||
if (existsSync(finalAbs)) rmSync(tmp, { force: true });
|
||||
else renameSync(tmp, finalAbs);
|
||||
saved.push({
|
||||
name: part.filename || finalName, type: part.mimetype,
|
||||
path: `tasks/${id}/attachments/${finalName}`, hash: digest, size,
|
||||
});
|
||||
}
|
||||
if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' });
|
||||
return { attachments: store.addAttachments(id, saved) };
|
||||
@@ -392,6 +419,9 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
});
|
||||
|
||||
// 删除单个附件:先删元数据(拿到 removed),再尽力删磁盘文件
|
||||
// 前提:附件目录按 task 隔离(tasks/<id>/attachments/),内容寻址后同一 hash 在一个 task 内
|
||||
// 去重为一条元数据 → 删元数据即可安全删盘,无需跨任务引用计数。
|
||||
// ⚠️ 若将来改为跨任务共享存储(同一 <sha256> 文件被多任务引用),必须引入 refcount,本次不做。
|
||||
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}` });
|
||||
|
||||
+4
-2
@@ -114,9 +114,11 @@ export interface Task {
|
||||
|
||||
/** 任务附件:图片/文件随任务提交,落盘于 <MAESTRO_DATA_DIR>/tasks/<taskId>/attachments/ */
|
||||
export interface Attachment {
|
||||
name: string; // 原始文件名
|
||||
name: string; // 原始文件名(展示用)
|
||||
type: string; // MIME 类型(如 image/png)
|
||||
path: string; // 相对 data 根的存储路径(tasks/<taskId>/attachments/<name>)
|
||||
path: string; // 相对 data 根的存储路径,内容寻址:tasks/<taskId>/attachments/<sha256>.<ext>
|
||||
hash?: string; // 内容 sha256(hex)——去重键;老数据无此字段,回退按 path 去重
|
||||
size?: number; // 字节数;老数据可能缺省
|
||||
}
|
||||
|
||||
export type RunKind = 'planner' | 'executor' | 'reviewer' | 'security';
|
||||
|
||||
+6
-4
@@ -420,10 +420,12 @@ 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[] : [];
|
||||
// 按 path 去重(later-wins):path 唯一对应一份磁盘文件,重传同名文件只保留最新一条元数据。
|
||||
const byPath = new Map<string, Attachment>();
|
||||
for (const a of [...cur, ...items]) byPath.set(a.path, a);
|
||||
const next = [...byPath.values()];
|
||||
// 按内容 hash 去重(later-wins):磁盘文件内容寻址,相同内容=同一份磁盘文件;
|
||||
// 重传相同内容(即便改名)只保留最新一条元数据(更新展示名/type)。老数据无 hash → 回退按 path 去重。
|
||||
const keyOf = (a: Attachment) => a.hash ?? a.path;
|
||||
const byKey = new Map<string, Attachment>();
|
||||
for (const a of [...cur, ...items]) byKey.set(keyOf(a), a);
|
||||
const next = [...byKey.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;
|
||||
|
||||
+98
-26
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store/index.js';
|
||||
@@ -30,20 +30,46 @@ function placeFile(dir: string, taskId: string, name: string, mime: string, body
|
||||
|
||||
// ───────────────────────── Store 层 ─────────────────────────
|
||||
|
||||
test('addAttachments:按 path 去重(later-wins)', () => {
|
||||
test('addAttachments:按内容 hash 去重(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 hA = 'a'.repeat(64);
|
||||
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', hash: hA, size: 3, path: `tasks/${taskId}/attachments/${hA}.png` }]);
|
||||
// 重传相同内容(hash 相同)但改名 → 元数据只保留一条,且取最新 name/type
|
||||
const hB = 'b'.repeat(64);
|
||||
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` },
|
||||
{ name: 'a-renamed.png', type: 'image/jpeg', hash: hA, size: 3, path: `tasks/${taskId}/attachments/${hA}.png` },
|
||||
{ name: 'b.pdf', type: 'application/pdf', hash: hB, size: 7, path: `tasks/${taskId}/attachments/${hB}.pdf` },
|
||||
]);
|
||||
assert.equal(next.length, 2);
|
||||
const a = next.find((x) => x.path.endsWith('/a.png'));
|
||||
const a = next.find((x) => x.hash === hA);
|
||||
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']);
|
||||
// 插入顺序保持:A 在前、B 在后
|
||||
assert.deepEqual(next.map((x) => x.hash), [hA, hB]);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('addAttachments:不同内容(hash 不同)即便同显示名也各保留一条(不再静默覆盖丢数据)', () => {
|
||||
const { store, taskId } = freshTask();
|
||||
const h1 = '1'.repeat(64);
|
||||
const h2 = '2'.repeat(64);
|
||||
const next = store.addAttachments(taskId, [
|
||||
{ name: 'screenshot.png', type: 'image/png', hash: h1, size: 3, path: `tasks/${taskId}/attachments/${h1}.png` },
|
||||
{ name: 'screenshot.png', type: 'image/png', hash: h2, size: 3, path: `tasks/${taskId}/attachments/${h2}.png` },
|
||||
]);
|
||||
assert.equal(next.length, 2, '不同内容同名应各保留一条');
|
||||
assert.deepEqual(next.map((x) => x.hash), [h1, h2]);
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('addAttachments:老数据无 hash 时回退按 path 去重', () => {
|
||||
const { store, taskId } = freshTask();
|
||||
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', path: `tasks/${taskId}/attachments/a.png` }]);
|
||||
const next = store.addAttachments(taskId, [
|
||||
{ name: 'a-renamed.png', type: 'image/jpeg', path: `tasks/${taskId}/attachments/a.png` },
|
||||
]);
|
||||
assert.equal(next.length, 1);
|
||||
assert.equal(next[0].name, 'a-renamed.png');
|
||||
store.close();
|
||||
});
|
||||
|
||||
@@ -198,38 +224,84 @@ test('DELETE:元数据在但磁盘文件已被外部删除 → 仍成功清元
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('上传→去重端到端:同名文件连传两次,元数据只一条', async () => {
|
||||
function multipart(filename: string, content: string): { payload: Buffer; headers: Record<string, string> } {
|
||||
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}` } };
|
||||
}
|
||||
|
||||
test('上传→内容去重端到端:同内容不同名连传 → 元数据 1 条、磁盘 1 份 <sha256>.png', 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<string, string> } {
|
||||
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}` } };
|
||||
}
|
||||
|
||||
// 同内容('aaa'),但文件名不同 → 内容寻址应去重为一条、磁盘一份
|
||||
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 m2 = multipart('shot-renamed.png', 'aaa');
|
||||
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, '同名重传应去重为一条');
|
||||
assert.equal(atts.length, 1, '同内容重传应去重为一条');
|
||||
|
||||
// 通过 GET /api/tasks/:id 复核
|
||||
// 磁盘上只有 1 个 <sha256>.png(外加可能的 .tmp-* 已清理)
|
||||
const attDir = join(dir, 'tasks', taskId, 'attachments');
|
||||
const files = readdirSync(attDir).filter((f) => !f.startsWith('.tmp-'));
|
||||
assert.equal(files.length, 1, '磁盘应只有一份内容文件');
|
||||
assert.match(files[0], /^[0-9a-f]{64}\.png$/, '磁盘名应为 <sha256>.png');
|
||||
|
||||
// GET /api/tasks/:id 复核:1 条,且含 hash(64 hex)/size 字段
|
||||
const t = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}` });
|
||||
assert.equal(t.json().attachments.length, 1);
|
||||
const list = t.json().attachments;
|
||||
assert.equal(list.length, 1);
|
||||
assert.match(list[0].hash, /^[0-9a-f]{64}$/);
|
||||
assert.equal(list[0].size, 3);
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('上传→内容寻址端到端:不同内容同显示名连传 → 元数据 2 条、磁盘 2 份(旧的覆盖丢数据 bug 已消除)', async () => {
|
||||
const dir = tmpData();
|
||||
process.env.MAESTRO_DATA_DIR = dir;
|
||||
const { store, taskId } = freshTask();
|
||||
const app = buildServer({ store });
|
||||
|
||||
const m1 = multipart('screenshot.png', 'aaa');
|
||||
await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m1.payload, headers: m1.headers });
|
||||
const m2 = multipart('screenshot.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);
|
||||
assert.equal(up2.json().attachments.length, 2, '不同内容同名应保留两条');
|
||||
|
||||
const attDir = join(dir, 'tasks', taskId, 'attachments');
|
||||
const files = readdirSync(attDir).filter((f) => !f.startsWith('.tmp-'));
|
||||
assert.equal(files.length, 2, '磁盘应有两份内容文件,不再静默覆盖');
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
|
||||
test('上传:响应附件含 hash(64 hex) 与 size 字段', async () => {
|
||||
const dir = tmpData();
|
||||
process.env.MAESTRO_DATA_DIR = dir;
|
||||
const { store, taskId } = freshTask();
|
||||
const app = buildServer({ store });
|
||||
const m = multipart('pic.png', 'hello-bytes');
|
||||
const up = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m.payload, headers: m.headers });
|
||||
assert.equal(up.statusCode, 200);
|
||||
const att = up.json().attachments[0];
|
||||
assert.match(att.hash, /^[0-9a-f]{64}$/);
|
||||
assert.equal(att.size, 'hello-bytes'.length);
|
||||
// 磁盘名 = path 末段 = <hash>.png
|
||||
assert.equal(att.path, `tasks/${taskId}/attachments/${att.hash}.png`);
|
||||
await app.close();
|
||||
store.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user