Files
maestro/src/daemon/notify.ts
T
wangjia 740d2c2637 feat: PR 审核流程 + 自动合并 + macOS 通知 + 订阅额度透传 + 归档详情
PR 流程(开发→提PR→CodeReview→安全审计→审核→通过即合并):
- reviewer 拆分为 code review 与安全审计两个独立 CC run(kind=reviewer/security),
  TaskResult 四字段(summary/verdict + securitySummary/securityVerdict),闸上两节报告两枚结论徽章
- executor/merge.ts: 通过即合并——临时 worktree 内 merge --no-ff,绝不碰用户工作区/不 push;
  冲突安全拒绝(报冲突文件);重复合并幂等;合并后回收执行 worktree + 删分支
- decide 路由 merge:false 逃生口 + 看板「仅通过」按钮(目标分支被工作区检出时用)

通知(daemon/notify.ts):
- macOS 原生通知: 进审核闸(复审建议拒绝标⚠)/连续失败需人工/合并完成
- 同任务同类型 60s 抑制、osascript 转义截断、MAESTRO_NOTIFY=0 关闭

订阅额度透传(daemon/usage.ts):
- OAuth usage API(与 Claude Code/claude-hud 同源),凭证 keychain→内存零泄漏
- 60s 成败双缓存+并发去重+5s 超时,失败降级 null
- GET /api/agents 顶层 usage 字段;Agent 面板显示 5h/周用量条+重置倒计时(>80%琥珀/>95%红)

看板与生命周期:
- 归档区: 深度1整树完成沉底,时间倒序分页(尺寸 chip 10/20/50/100 置底)
- 归档详情对话框: 全属性/执行历史与时长/审批记录/状态流转时间线(GET /api/tasks/:id/events)
- 容器收口: 已拆解 Hard 子任务全 done 自动 done(afterDone 逐级向上)
- 同步按钮收进配置面板;执行白名单扩测试命令(npm/go/shellcheck/make/pytest)
- Agent 面板显示调度模式与各复杂度模型;被依赖阻塞→被阻塞

测试: 74/74(新增 merge 6/notify 10/usage 7/容器收口/双复审适配)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:22:30 +08:00

115 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFile } from 'node:child_process';
import type { Store } from '../store/index.js';
import type { Event } from '../model/types.js';
export interface NotifyLogger {
info(msg: string): void;
error(msg: string): void;
}
/** osascript 执行函数(测试注入假实现,不真发通知) */
export type OsascriptFn = (script: string) => void;
/** 同一任务同类型通知的抑制窗口 */
export const NOTIFY_SUPPRESS_MS = 60_000;
const MAX_LEN = 80;
/** AppleScript 字符串注入防护:去换行、转义反斜杠与双引号,截断 80 字符 */
export function sanitizeForOsascript(s: string): string {
const oneLine = String(s).replace(/[\r\n]+/g, ' ').trim();
const cut = oneLine.length > MAX_LEN ? `${oneLine.slice(0, MAX_LEN - 1)}` : oneLine;
return cut.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
const GATE_NAME: Record<string, string> = { plan_review: '拆解', spec_review: '方案', exec_review: 'PR' };
interface NotifierOptions {
osascript?: OsascriptFn;
now?: () => number;
}
/**
* 事件 → 通知文案(不触发的事件返回 null)。kind 用于同任务同类型 60s 抑制。
* - status.changed → plan/spec/exec_review:「⏳ <任务名> 等待审核(拆解/方案/PR)」;
* exec_review 且复审任一 verdict=reject:「⚠ <任务名> 复审建议拒绝(PR 待审核)」
* - status.changed → needs_attention:「❌ <任务名> 连续失败需人工」
* - approval.granted 且 gate=exec:「✅ <任务名> 已合并完成」
*/
export function buildNotification(store: Store, e: Event): { kind: string; message: string } | null {
if (!e.taskId) return null;
if (e.type === 'status.changed') {
const to = String(e.payload.to ?? '');
if (to === 'needs_attention') {
const t = store.getTask(e.taskId);
if (!t) return null;
return { kind: 'attention', message: `${t.title} 连续失败需人工` };
}
if (to === 'plan_review' || to === 'spec_review' || to === 'exec_review') {
const t = store.getTask(e.taskId);
if (!t) return null;
if (to === 'exec_review' && (t.result?.verdict === 'reject' || t.result?.securityVerdict === 'reject')) {
return { kind: 'gate', message: `${t.title} 复审建议拒绝(PR 待审核)` };
}
return { kind: 'gate', message: `${t.title} 等待审核(${GATE_NAME[to]}` };
}
return null;
}
if (e.type === 'approval.granted' && e.payload.gate === 'exec') {
const t = store.getTask(e.taskId);
if (!t) return null;
return { kind: 'merged', message: `${t.title} 已合并完成` };
}
return null;
}
/**
* 创建通知器:订阅 store 事件流,符合条件时发 macOS 原生通知(osascript display notification)。
* 同一任务同类型 60s 内只发一次;osascript 失败只记日志。返回取消订阅函数。
*/
export function createNotifier(store: Store, log: NotifyLogger, opts: NotifierOptions = {}): () => void {
const now = opts.now ?? Date.now;
const osa: OsascriptFn = opts.osascript ?? ((script) => {
execFile('osascript', ['-e', script], (err) => {
if (err) log.error(`macOS 通知发送失败:${err.message}`);
});
});
const lastSent = new Map<string, number>(); // `${taskId}:${kind}` → 最近发送时刻
return store.subscribe((e) => {
try {
const n = buildNotification(store, e);
if (!n) return;
const key = `${e.taskId}:${n.kind}`;
const t = now();
const prev = lastSent.get(key);
if (prev !== undefined && t - prev < NOTIFY_SUPPRESS_MS) return;
lastSent.set(key, t);
osa(`display notification "${sanitizeForOsascript(n.message)}" with title "maestro"`);
} catch (err) {
log.error(`通知处理失败:${(err as Error).message}`);
}
});
}
/**
* 接线入口(照 startSyncLoop 模式):MAESTRO_NOTIFY=0 关闭;非 macOS 平台不启用。
* 返回取消订阅函数(shutdown 时调用),未启用时返回 null。
*/
export function startNotifier(store: Store, app: { log: NotifyLogger }): (() => void) | null {
if (process.env.MAESTRO_NOTIFY === '0') {
app.log.info('macOS 通知已关闭(MAESTRO_NOTIFY=0');
return null;
}
if (process.platform !== 'darwin') {
app.log.info('非 macOS 平台,原生通知不启用');
return null;
}
const stop = createNotifier(store, app.log);
app.log.info('macOS 通知已启用:审核闸/需人工/合并完成(同任务同类型 60s 抑制,MAESTRO_NOTIFY=0 关闭)');
return stop;
}