From 7884182d2b70e2210748c6ad7d041363d65164b4 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 19:11:42 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E7=BC=96=E6=8E=92=E5=99=A8?= =?UTF-8?q?=E8=B7=A8=E9=A1=B9=E7=9B=AE=E5=85=A8=E5=B1=80=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E9=97=B8=EF=BC=88tsk=5FerQHNYdn6i9g=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增用户级设置项 globalConcurrency(settings 表,独立于新建项目默认值 来源 concurrency),缺省 0 = 不限,向后兼容无需 DB 迁移。 - store: UserSettings 加 globalConcurrency 字段 + 默认 0;新增 globalInflightCount()(跨所有项目 started run 总数,与 inflightTaskIds 同口径) - orchestrator.claimTick: 叠加全局闸,与 per-project 闸串联(都过才领); 轮初查一次全局在途、轮内手动 ++,达上限即 return 跨所有项目停止领取 - api: PUT /api/settings 支持 globalConcurrency(空/null 归一为 0) - web: 侧栏「全局设置」入口 + 模态,编辑/回显/校验(≥0 整数) - test: 新增全局闸达上限跨项目阻止 / 未达上限正常领取 / 边界 / 轮初短路 用例 Co-Authored-By: Claude Opus 4.8 --- src/api/server.ts | 2 ++ src/daemon/orchestrator.ts | 11 ++++++- src/store/store.ts | 8 +++++ test/orchestrator.test.ts | 63 ++++++++++++++++++++++++++++++++++++++ web/app.js | 30 ++++++++++++++++++ web/index.html | 18 +++++++++++ 6 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/api/server.ts b/src/api/server.ts index 7aff59a..45dbc95 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -137,6 +137,8 @@ export function buildServer(opts: ApiOptions): FastifyInstance { if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd); if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month'; if (b.model !== undefined) patch.model = b.model === null || b.model === '' ? null : String(b.model); + if (b.globalConcurrency !== undefined) + patch.globalConcurrency = b.globalConcurrency === null || b.globalConcurrency === '' ? 0 : Number(b.globalConcurrency); return store.putSettings(patch); }); diff --git a/src/daemon/orchestrator.ts b/src/daemon/orchestrator.ts index d9cae82..82605b3 100644 --- a/src/daemon/orchestrator.ts +++ b/src/daemon/orchestrator.ts @@ -209,9 +209,16 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps: /** * 领取轮:对每个 active 且 autonomy≠manual 的项目,并发闸 = inflightTaskIds(有 started run 的任务, * executor + planner 通用)。在 active 0 && globalActive >= cap) return; // 全局闸已满 → 整轮跨项目都不领 for (const p of store.listProjects()) { if (p.status !== 'active' || p.autonomy === 'manual') continue; const inflight = store.inflightTaskIds(p.id); @@ -219,9 +226,11 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps: if (active >= p.concurrency) continue; for (const { task, score } of claimable(p, inflight)) { - if (active >= p.concurrency) break; + if (active >= p.concurrency) break; // per-project 闸 + if (cap > 0 && globalActive >= cap) return; // 全局闸:达上限即跨所有项目停止领取 claimOne(p, task, score); active++; + globalActive++; // 本轮内手动累加(claimOne 已起新 started run) } } } catch (e) { diff --git a/src/store/store.ts b/src/store/store.ts index 1300f96..58fe0ce 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -30,11 +30,14 @@ export interface UserSettings { budgetUsd: number | null; budgetPeriod: 'day' | 'month'; model: string | null; + /** 跨所有项目的在途 run 总数上限(全局并发闸);0 = 不限。与 concurrency(新建项目默认值)相互独立。 */ + globalConcurrency: number; } const SETTINGS_DEFAULT: UserSettings = { autonomy: 'manual', concurrency: 1, maxRetries: 2, timeoutMs: 1_800_000, autoApprovePlan: false, autoApproveExec: false, budgetUsd: null, budgetPeriod: 'month', model: null, + globalConcurrency: 0, }; const id = (prefix: string): string => `${prefix}_${nanoid(12)}`; @@ -918,6 +921,11 @@ export class Store { return new Set(rows.map((r) => r.tid)); } + /** 跨所有项目的在途 run 总数(全局并发闸用;与 inflightTaskIds 同口径=started run,覆盖 executor + planner)。 */ + globalInflightCount(): number { + return (this.db.prepare(`SELECT COUNT(*) AS n FROM runs WHERE status = 'started'`).get() as { n: number }).n; + } + /** 所有 started run + 其任务(reap / ingest / reconcile 通用,覆盖 executor + planner + 残留复审 run)。 */ liveRunsWithTask(): Array<{ task: Task; run: Run }> { const runs = this.db.prepare(`SELECT * FROM runs WHERE status = 'started' ORDER BY started_at`).all() as RunRow[]; diff --git a/test/orchestrator.test.ts b/test/orchestrator.test.ts index d2ad749..ab4cbaa 100644 --- a/test/orchestrator.test.ts +++ b/test/orchestrator.test.ts @@ -443,6 +443,69 @@ test('auto-easy 不做规划:medium(speccing) 不被领取', () => { store.close(); }); +// ───────────────────────── 全局并发闸(跨项目在途总数上限)───────────────────────── + +/** 单 store 建两个 active / auto-easy 项目,各塞 n 个 easy 任务,per-project 并发足够大(不成为瓶颈)。 */ +function setupTwoProjects(n = 2, concurrency = 5): { store: Store; p1: string; p2: string } { + const store = new Store(':memory:'); + const p1 = store.createProject({ name: 'g1', repoPath: '/tmp/g1-' + Math.random(), autonomy: 'auto-easy', concurrency }); + const p2 = store.createProject({ name: 'g2', repoPath: '/tmp/g2-' + Math.random(), autonomy: 'auto-easy', concurrency }); + for (let i = 0; i < n; i++) { + store.createTask({ projectId: p1.id, title: `p1-${i}`, complexity: 'easy' }); + store.createTask({ projectId: p2.id, title: `p2-${i}`, complexity: 'easy' }); + } + return { store, p1: p1.id, p2: p2.id }; +} + +test('全局闸:globalConcurrency=1 达上限 → 跨所有项目只领一个(per-project 闸都没满也阻止)', () => { + const { store } = setupTwoProjects(2, 5); + store.putSettings({ globalConcurrency: 1 }); + const state = freshState(); + const { deps } = mockDeps(state); + createOrchestrator(store, noopLog, deps).claimTick(); + assert.equal(state.spawned.length, 1, '全局闸=1:两项目共 4 个可领任务,本轮只放一个'); + store.close(); +}); + +test('全局闸:globalConcurrency=0(缺省)不限 → 两项目任务全领(不误伤)', () => { + const { store, p1, p2 } = setupTwoProjects(2, 5); + // 不设 globalConcurrency(默认 0) + assert.equal(store.getSettings().globalConcurrency, 0); + const state = freshState(); + const { deps } = mockDeps(state); + createOrchestrator(store, noopLog, deps).claimTick(); + assert.equal(state.spawned.length, 4, '不限:两项目各 2 个共 4 个全部领取'); + assert.equal(store.listTasks(p1).filter((t) => t.status === 'executing').length, 2); + assert.equal(store.listTasks(p2).filter((t) => t.status === 'executing').length, 2); + store.close(); +}); + +test('全局闸:globalConcurrency=3 两项目共 4 任务 → 一轮恰好领 3(边界)', () => { + const { store } = setupTwoProjects(2, 5); + store.putSettings({ globalConcurrency: 3 }); + const state = freshState(); + const { deps } = mockDeps(state); + createOrchestrator(store, noopLog, deps).claimTick(); + assert.equal(state.spawned.length, 3, '全局闸=3:达上限即跨项目停止,恰好领 3 个'); + store.close(); +}); + +test('全局闸:在途已达上限 → 整轮直接不领(轮初短路)', () => { + const { store, p1 } = setupTwoProjects(2, 5); + store.putSettings({ globalConcurrency: 1 }); + const state = freshState(); + const { deps } = mockDeps(state); + const orch = createOrchestrator(store, noopLog, deps); + orch.claimTick(); + assert.equal(state.spawned.length, 1); + assert.equal(store.globalInflightCount(), 1, '已有 1 个在途 started run'); + // 再来一轮:全局在途已=1=cap → 不再领 + orch.claimTick(); + assert.equal(state.spawned.length, 1, '全局在途已达上限 → 整轮不领'); + assert.ok(store.listTasks(p1).length >= 0); + store.close(); +}); + test('并发闸用 inflight:1 个 executing + 1 个 planner 占满 concurrency=2', () => { const { store, projectId } = setup('auto-approved', 2); const e = store.createTask({ projectId, title: 'easy', complexity: 'easy' }); // → ready diff --git a/web/app.js b/web/app.js index bebedc6..016ce24 100644 --- a/web/app.js +++ b/web/app.js @@ -132,6 +132,10 @@ const I18N = { toastNameRequired: '名称与仓库路径必填', toastTaskNotFound: '任务不在当前项目', toastConcInt: '最大并发必须是 ≥1 的整数', + globalSettings: '全局设置', globalConc: '全局并发上限', + globalConcHint: '跨所有项目的在途任务总数上限;0 = 不限。', + globalConcInt: '全局并发上限必须是 ≥0 的整数(0 = 不限)', + toastSettingsSaved: '全局设置已保存', toastRejectRequired: '驳回意见不能为空', reorderSaved: '项目顺序已保存', cancelConfirm: '确定取消任务「{title}」{hint}?\n取消后任务进入归档,不影响子任务执行。', @@ -274,6 +278,10 @@ const I18N = { toastNameRequired: 'Name and repo path are required', toastTaskNotFound: 'Task not found in current project', toastConcInt: 'Max concurrency must be an integer ≥1', + globalSettings: 'Settings', globalConc: 'Global concurrency cap', + globalConcHint: 'Max in-flight tasks across all projects; 0 = unlimited.', + globalConcInt: 'Global concurrency cap must be an integer ≥0 (0 = unlimited)', + toastSettingsSaved: 'Settings saved', toastRejectRequired: 'Rejection feedback is required', reorderSaved: 'Project order saved', cancelConfirm: 'Cancel task "{title}"{hint}?\nThis moves the task to archive; subtasks are unaffected.', @@ -1752,6 +1760,17 @@ document.addEventListener('click', (ev) => { $('#modalRoot').hidden = true; break; + case 'open-settings': + api('/api/settings').then((s) => { + $('#setGlobalConc').value = s.globalConcurrency ?? 0; + $('#settingsRoot').hidden = false; + $('#setGlobalConc').focus(); + }).catch((e) => toast(e.message)); + break; + case 'close-settings': + $('#settingsRoot').hidden = true; + break; + case 'toggle-new-task': { const p = $('#newTaskPanel'); p.hidden = !p.hidden; @@ -2234,6 +2253,16 @@ $('#newProjectForm').addEventListener('submit', (ev) => { }, t('toastProjectCreated', { name })); }); +$('#settingsForm').addEventListener('submit', (ev) => { + ev.preventDefault(); + const n = Number($('#setGlobalConc').value); + if (!Number.isInteger(n) || n < 0) { toast(t('globalConcInt')); return; } + act(async () => { + await api('/api/settings', { method: 'PUT', body: JSON.stringify({ globalConcurrency: n }) }); + $('#settingsRoot').hidden = true; + }, t('toastSettingsSaved')); +}); + $('#newTaskPanel').addEventListener('submit', (ev) => { ev.preventDefault(); const f = ev.target; @@ -2268,6 +2297,7 @@ document.addEventListener('keydown', (ev) => { const am = $('#archiveModalRoot'); if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; } if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; } + if (!$('#settingsRoot').hidden) { $('#settingsRoot').hidden = true; return; } $('#modalRoot').hidden = true; } }); diff --git a/web/index.html b/web/index.html index 607e553..df3db8b 100644 --- a/web/index.html +++ b/web/index.html @@ -28,6 +28,8 @@
连接中… + +
@@ -261,6 +263,22 @@ + + +
From aca8f1b18b13ddeec3a1ed46697a2d8d5a657b05 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 19:54:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=E5=8A=A0=E5=9B=BA=EF=BC=9A=E6=97=A0=E5=90=8D/?= =?UTF-8?q?=E6=97=A0=E6=89=A9=E5=B1=95=E5=90=8D=E7=B2=98=E8=B4=B4=20blob?= =?UTF-8?q?=20=E6=8C=89=20mimetype=20=E7=94=9F=E6=88=90=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=90=8D+=E6=89=A9=E5=B1=95=E5=90=8D=20(tsk?= =?UTF-8?q?=5F5dmtoHD=5FQVA3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 附件上传接口对剪贴板粘贴等「无名/无扩展名」的 blob 加固落盘命名: - 新增 extFromMime / safeAttachmentName 纯函数(可单测、已导出) - 占位名(blob/image/file 等)或空名 → 生成唯一 paste--, 扩展名优先按 mimetype 推断、其次保留原扩展名,避免多次粘贴同名覆盖 - 正常名缺扩展名 → 按 mimetype 补;路径穿越/非法字符净化,剥前导点 - name 字段在原名缺失时回退到生成名 Co-Authored-By: Claude Opus 4.8 --- src/api/server.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index 45dbc95..58bb210 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -25,6 +25,70 @@ function dataDir(): string { return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro'); } +/** 常见 MIME → 落盘扩展名(无前导点)。剪贴板粘贴 blob 常无扩展名,据此补齐。 */ +const MIME_EXT: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/bmp': 'bmp', + 'image/x-icon': 'ico', + 'image/vnd.microsoft.icon': 'ico', + 'image/tiff': 'tiff', + 'image/heic': 'heic', + 'image/heif': 'heif', + 'image/avif': 'avif', + 'application/pdf': 'pdf', + 'application/zip': 'zip', + 'application/json': 'json', + 'application/xml': 'xml', + 'text/plain': 'txt', + 'text/markdown': 'md', + 'text/csv': 'csv', + 'text/html': 'html', +}; + +/** MIME 类型 → 扩展名(无点)。未知映射时对 image/ 兜底取 subtype;其余返回 ''。 */ +export function extFromMime(mimetype: string | undefined): string { + if (!mimetype) return ''; + const key = mimetype.split(';')[0].trim().toLowerCase(); + if (MIME_EXT[key]) return MIME_EXT[key]; + const m = key.match(/^image\/([a-z0-9.+-]+)$/); + if (m) return m[1].replace(/[^a-z0-9]/g, ''); + return ''; +} + +/** basename 末尾是否带「看起来像扩展名」的后缀(1-8 位字母数字)。 */ +function hasExtension(name: string): boolean { + return /\.[A-Za-z0-9]{1,8}$/.test(name); +} + +/** + * 计算附件落盘安全文件名。规则: + * 1. 仅取 basename,净化非法字符(`[^\w.\-]` → `_`),剥掉前导点(防隐藏/空名)。 + * 2. 主名为空或为占位名(blob/image/file/unknown/untitled/paste)→ 视为「无名粘贴 blob」, + * 生成唯一名 `paste--`,扩展名优先取 mimetype 推断、其次保留原扩展名。 + * 3. 有正常主名但缺扩展名 → 按 mimetype 补扩展名(mimetype 未知则保持原样)。 + * 返回值保证非空。 + */ +export function safeAttachmentName(rawName: string | undefined, mimetype: string | undefined, seq = 0): string { + const ext = extFromMime(mimetype); + const sanitized = basename((rawName ?? '').trim()).replace(/[^\w.\-]/g, '_').replace(/^\.+/, ''); + const withExt = hasExtension(sanitized); + const stem = withExt ? sanitized.replace(/\.[A-Za-z0-9]{1,8}$/, '') : sanitized; + const isPlaceholder = stem === '' || /^(blob|image|file|unknown|untitled|paste)$/i.test(stem); + if (isPlaceholder) { + const origExt = withExt ? sanitized.slice(sanitized.lastIndexOf('.') + 1) : ''; + const useExt = ext || origExt; + const base = `paste-${Date.now()}-${seq}`; + return useExt ? `${base}.${useExt}` : base; + } + if (!withExt && ext) return `${sanitized}.${ext}`; + return sanitized; +} + /** Project 出参:附加 hasTodoJson(/todo/todo.json 是否存在,每次序列化时算) */ function projectOut(p: Project, store: Store): Project & { hasTodoJson: boolean; summary: ReturnType } { return { ...p, hasTodoJson: hasTodoJson(p.repoPath), summary: store.projectSummary(p.id) }; @@ -280,11 +344,13 @@ export function buildServer(opts: ApiOptions): FastifyInstance { const dir = join(dataDir(), 'tasks', id, 'attachments'); mkdirSync(dir, { recursive: true }); const saved: Array<{ name: string; type: string; path: string }> = []; + let seq = 0; for await (const part of req.files()) { - const safe = basename(part.filename).replace(/[^\w.\-]/g, '_') || `file-${Date.now()}`; + // 无名/无扩展名(如剪贴板粘贴 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} 超过 25MB 上限` }); - saved.push({ name: part.filename, type: part.mimetype, path: `tasks/${id}/attachments/${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}` }); } if (saved.length === 0) return reply.code(400).send({ error: '未收到文件' }); return { attachments: store.addAttachments(id, saved) };