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:
@@ -31,6 +31,18 @@ export const api = {
|
|||||||
createTask: (pid, body) => http('POST', `/api/projects/${pid}/tasks`, body),
|
createTask: (pid, body) => http('POST', `/api/projects/${pid}/tasks`, body),
|
||||||
patchProject: (pid, body) => http('PATCH', `/api/projects/${pid}`, body),
|
patchProject: (pid, body) => http('PATCH', `/api/projects/${pid}`, body),
|
||||||
createProject: (body) => http('POST', '/api/projects', body),
|
createProject: (body) => http('POST', '/api/projects', body),
|
||||||
|
// 附件上传走 multipart(不能用 JSON helper,content-type 由浏览器带 boundary)
|
||||||
|
uploadAttachments: async (taskId, files) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const f of files) fd.append('files', f, f.name);
|
||||||
|
const res = await fetch(`/api/tasks/${taskId}/attachments`, { method: 'POST', body: fd });
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = res.statusText;
|
||||||
|
try { msg = (await res.json()).error || msg; } catch { /* 非 JSON */ }
|
||||||
|
throw new Error(`上传附件失败 → ${res.status} ${msg}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// WS 单向订阅:连上后服务端推送 events 表记录。断线自动重连(指数退避,封顶 10s)。
|
// WS 单向订阅:连上后服务端推送 events 表记录。断线自动重连(指数退避,封顶 10s)。
|
||||||
|
|||||||
+6
-1
@@ -208,8 +208,13 @@ export function App() {
|
|||||||
catch (e) { toast('warn', '同步失败:' + e.message); }
|
catch (e) { toast('warn', '同步失败:' + e.message); }
|
||||||
};
|
};
|
||||||
const createTask = async (payload) => {
|
const createTask = async (payload) => {
|
||||||
|
const { files, ...body } = payload;
|
||||||
try {
|
try {
|
||||||
await api.createTask(currentId, payload);
|
const task = await api.createTask(currentId, body);
|
||||||
|
if (files && files.length) {
|
||||||
|
try { await api.uploadAttachments(task.id, files); toast('ok', `附件已上传 · ${files.length} 个`); }
|
||||||
|
catch (e) { toast('warn', e.message); }
|
||||||
|
}
|
||||||
toast('ok', t.toastCreated);
|
toast('ok', t.toastCreated);
|
||||||
loadProject(currentId); loadGlobal();
|
loadProject(currentId); loadGlobal();
|
||||||
} catch (e) { toast('warn', '创建失败:' + e.message); }
|
} catch (e) { toast('warn', '创建失败:' + e.message); }
|
||||||
|
|||||||
@@ -205,10 +205,11 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
|
|||||||
const [complexity, setComplexity] = React.useState('auto');
|
const [complexity, setComplexity] = React.useState('auto');
|
||||||
const [priority, setPriority] = React.useState('1');
|
const [priority, setPriority] = React.useState('1');
|
||||||
const [parentId, setParentId] = React.useState('');
|
const [parentId, setParentId] = React.useState('');
|
||||||
|
const [files, setFiles] = React.useState([]);
|
||||||
const submit = (e) => {
|
const submit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
onCreate({ title: title.trim(), complexity, priority: Number(priority), parentId: parentId || null });
|
onCreate({ title: title.trim(), complexity, priority: Number(priority), parentId: parentId || null, files });
|
||||||
};
|
};
|
||||||
// 扁平化任务树供父任务下拉(含子任务)
|
// 扁平化任务树供父任务下拉(含子任务)
|
||||||
const flat = [];
|
const flat = [];
|
||||||
@@ -232,6 +233,18 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
|
|||||||
<Select label={t.parentTask} style={{ width: '100%' }} onChange={setParentId} options={[{ value: '', label: t.topLevel }, ...flat]} />
|
<Select label={t.parentTask} style={{ width: '100%' }} onChange={setParentId} options={[{ value: '', label: t.topLevel }, ...flat]} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
|
||||||
|
<span>{t.attachLabel || '附件 · 图片/文件(可选)'}</span>
|
||||||
|
<input type="file" multiple onChange={(e) => setFiles([...e.target.files])}
|
||||||
|
style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} />
|
||||||
|
</label>
|
||||||
|
{files.length ? (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--faint)' }}>
|
||||||
|
{files.map((f) => f.name).join(' · ')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line-soft)' }}>
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line-soft)' }}>
|
||||||
<Button onClick={onCancel}>{t.cancel}</Button>
|
<Button onClick={onCancel}>{t.cancel}</Button>
|
||||||
<Button variant="solid" type="submit">{t.create}</Button>
|
<Button variant="solid" type="submit">{t.create}</Button>
|
||||||
|
|||||||
Generated
+68
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.175",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.175",
|
||||||
|
"@fastify/multipart": "^8.3.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
@@ -643,6 +644,28 @@
|
|||||||
"fast-uri": "^2.0.0"
|
"fast-uri": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/busboy": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/deepmerge": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@fastify/error": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
|
||||||
@@ -667,6 +690,36 @@
|
|||||||
"fast-deep-equal": "^3.1.3"
|
"fast-deep-equal": "^3.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/multipart": {
|
||||||
|
"version": "8.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-8.3.1.tgz",
|
||||||
|
"integrity": "sha512-pncbnG28S6MIskFSVRtzTKE9dK+GrKAJl0NbaQ/CG8ded80okWFsYKzSlP9haaLNQhNRDOoHqmGQNvgbiPVpWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/busboy": "^3.0.0",
|
||||||
|
"@fastify/deepmerge": "^2.0.0",
|
||||||
|
"@fastify/error": "^4.0.0",
|
||||||
|
"fastify-plugin": "^4.0.0",
|
||||||
|
"secure-json-parse": "^2.4.0",
|
||||||
|
"stream-wormhole": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/multipart/node_modules/@fastify/error": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
@@ -1461,6 +1514,12 @@
|
|||||||
"toad-cache": "^3.3.0"
|
"toad-cache": "^3.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fastify-plugin": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -2558,6 +2617,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stream-wormhole": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stream-wormhole/-/stream-wormhole-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.175",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.175",
|
||||||
|
"@fastify/multipart": "^8.3.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
|||||||
+30
-1
@@ -13,9 +13,18 @@ import { git, removeWorktree } from '../executor/worktree.js';
|
|||||||
import { cleanupTaskRunArtifacts } from '../executor/cleanup.js';
|
import { cleanupTaskRunArtifacts } from '../executor/cleanup.js';
|
||||||
import { resolveLogo, LOGO_MIME } from './logo.js';
|
import { resolveLogo, LOGO_MIME } from './logo.js';
|
||||||
import { readTranscript, TranscriptError } from '../executor/transcript.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';
|
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 是否存在,每次序列化时算) */
|
/** Project 出参:附加 hasTodoJson(<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
|
||||||
function projectOut(p: Project): Project & { hasTodoJson: boolean } {
|
function projectOut(p: Project): Project & { hasTodoJson: boolean } {
|
||||||
return { ...p, hasTodoJson: hasTodoJson(p.repoPath) };
|
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 classify = opts.classify ?? ((title, description, cwd) => classifyComplexity(title, description, { cwd }));
|
||||||
const app = Fastify({ logger: opts.logger ?? false });
|
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
|
// Store 错误 → 400(业务校验),其余 → 500
|
||||||
app.setErrorHandler((err, _req, reply) => {
|
app.setErrorHandler((err, _req, reply) => {
|
||||||
if (err instanceof StoreError) return reply.code(400).send({ error: err.message });
|
if (err instanceof StoreError) return reply.code(400).send({ error: err.message });
|
||||||
@@ -218,6 +230,23 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
|||||||
return t;
|
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/complexity;complexity 重置逻辑在 Store.patchTask)
|
// 部分更新任务(title/priority/complexity;complexity 重置逻辑在 Store.patchTask)
|
||||||
app.patch('/api/tasks/:id', (req) => {
|
app.patch('/api/tasks/:id', (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
@@ -83,10 +83,18 @@ export interface Task {
|
|||||||
retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用)
|
retryBaseline: number; // 上次手动重投时已有的失败 run 数(重置重试计数用)
|
||||||
nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领
|
nextEligibleAt: string | null; // 持久化退避:早于此时间不被领取(重试退避,重启不丢);null=即刻可领
|
||||||
lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示)
|
lastRunError?: string | null; // needs_attention 时:最近一次失败 run 的错误信息(供审核区展示)
|
||||||
|
attachments?: Attachment[]; // 随任务提交的图片/文件(存 <data>/tasks/<id>/attachments/)
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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 RunKind = 'planner' | 'executor' | 'reviewer' | 'security';
|
||||||
export type RunStatus = 'started' | 'succeeded' | 'failed' | 'cancelled';
|
export type RunStatus = 'started' | 'succeeded' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function openDb(file: string): Database.Database {
|
|||||||
ensureColumn(db, 'runs', 'cost_usd', 'cost_usd REAL'); // 该 run 折算成本 USD(按模型×token)
|
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_usd', 'budget_usd REAL'); // 项目当期预算上限 USD(null=不限)
|
||||||
ensureColumn(db, 'projects', 'budget_period', "budget_period TEXT NOT NULL DEFAULT 'month'"); // 预算周期 day|month
|
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');
|
const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8');
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface TaskRow {
|
|||||||
next_eligible_at: string | null;
|
next_eligible_at: string | null;
|
||||||
created_at: string; updated_at: string;
|
created_at: string; updated_at: string;
|
||||||
source_ref: string | null;
|
source_ref: string | null;
|
||||||
|
attachments: string | null;
|
||||||
last_run_error?: string | null; // pendingApprovals 扩展字段(subquery)
|
last_run_error?: string | null; // pendingApprovals 扩展字段(subquery)
|
||||||
}
|
}
|
||||||
export interface ApprovalRow {
|
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,
|
assignee: r.assignee as Task['assignee'], retryBaseline: r.retry_baseline ?? 0,
|
||||||
nextEligibleAt: r.next_eligible_at ?? null,
|
nextEligibleAt: r.next_eligible_at ?? null,
|
||||||
lastRunError: r.last_run_error ?? 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,
|
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
next_eligible_at TEXT, -- 持久化退避:早于此时间不被领取(null=即刻可领)
|
next_eligible_at TEXT, -- 持久化退避:早于此时间不被领取(null=即刻可领)
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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_project ON tasks(project_id, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
||||||
|
|||||||
+13
-2
@@ -4,7 +4,7 @@ import {
|
|||||||
rowToProject, rowToTask, rowToApproval, rowToRun, rowToEvent,
|
rowToProject, rowToTask, rowToApproval, rowToRun, rowToEvent,
|
||||||
type ProjectRow, type TaskRow, type ApprovalRow, type RunRow, type EventRow,
|
type ProjectRow, type TaskRow, type ApprovalRow, type RunRow, type EventRow,
|
||||||
} from './mappers.js';
|
} 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 { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js';
|
||||||
import type { Complexity } from '../model/complexity.js';
|
import type { Complexity } from '../model/complexity.js';
|
||||||
import { rankByScore } from '../model/scoring.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,
|
title: input.title, complexity: input.complexity, status, priority: input.priority ?? 1,
|
||||||
deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null,
|
deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null,
|
||||||
result: null, assignee: null, retry_baseline: 0, next_eligible_at: 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(
|
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)
|
`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);
|
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 {
|
private getTaskRow(taskId: string): TaskRow | undefined {
|
||||||
return this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId) as TaskRow | undefined;
|
return this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId) as TaskRow | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user