Merge branch 'main' into worktree-tidy-jumping-shell

This commit is contained in:
wangjia
2026-06-29 23:32:40 +08:00
6 changed files with 200 additions and 4 deletions
+71 -3
View File
@@ -25,6 +25,70 @@ function dataDir(): string {
return process.env.MAESTRO_DATA_DIR ?? join(homedir(), '.maestro');
}
/** 常见 MIME → 落盘扩展名(无前导点)。剪贴板粘贴 blob 常无扩展名,据此补齐。 */
const MIME_EXT: Record<string, string> = {
'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> 兜底取 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-<ts>-<seq>`,扩展名优先取 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<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
function projectOut(p: Project, store: Store): Project & { hasTodoJson: boolean; summary: ReturnType<Store['projectSummary']> } {
return { ...p, hasTodoJson: hasTodoJson(p.repoPath), summary: store.projectSummary(p.id) };
@@ -137,6 +201,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);
});
@@ -278,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) };
+10 -1
View File
@@ -209,9 +209,16 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps:
/**
* 领取轮:对每个 active 且 autonomy≠manual 的项目,并发闸 = inflightTaskIds(有 started run 的任务,
* executor + planner 通用)。在 active<concurrency 时按 claimable 领新任务并 spawn worker。
*
* 全局并发闸(settings.globalConcurrency0/缺省=不限)与 per-project 闸【串联】:两闸都过才领。
* 全局在途总数(globalActive)轮初查一次、轮内手动 ++claimOne 同步起新 started run),命中即 return——
* 达上限后任何项目都不应再领,故 return(跨所有项目停止)而非 break(只停当前项目)。
*/
function claimTick(): void {
try {
const cap = store.getSettings().globalConcurrency; // 0/缺省 = 不限
let globalActive = store.globalInflightCount(); // 轮初的全局在途总数
if (cap > 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) {
+8
View File
@@ -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)}`;
@@ -936,6 +939,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[];