740d2c2637
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>
169 lines
7.0 KiB
TypeScript
169 lines
7.0 KiB
TypeScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { Store } from '../src/store/index.js';
|
||
import { createNotifier, startNotifier, sanitizeForOsascript, NOTIFY_SUPPRESS_MS } from '../src/daemon/notify.js';
|
||
|
||
const noopLog = { info: (): void => undefined, error: (): void => undefined };
|
||
|
||
function setup(): { store: Store; projectId: string } {
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({ name: 'notify', repoPath: '/tmp/notify-repo-' + Math.random() });
|
||
return { store, projectId: p.id };
|
||
}
|
||
|
||
/** 注入假 osascript:捕获脚本文本,不真发通知 */
|
||
function capture(store: Store, now?: () => number): { scripts: string[]; stop: () => void } {
|
||
const scripts: string[] = [];
|
||
const stop = createNotifier(store, noopLog, { osascript: (s) => scripts.push(s), ...(now ? { now } : {}) });
|
||
return { scripts, stop };
|
||
}
|
||
|
||
const emptyResult = {
|
||
branch: 'maestro/x', worktree: '/wt', diffSummary: null, commits: [], prUrl: null,
|
||
summary: null, verdict: null, securitySummary: null, securityVerdict: null,
|
||
} as const;
|
||
|
||
test('notify:进入 spec_review 闸 →「等待审核(方案)」;与任务名一致', () => {
|
||
const { store, projectId } = setup();
|
||
const t = store.createTask({ projectId, title: '写登录方案', complexity: 'medium' });
|
||
const { scripts } = capture(store);
|
||
store.setSpec(t.id, '方案');
|
||
store.transition(t.id, 'spec_review');
|
||
assert.equal(scripts.length, 1);
|
||
assert.match(scripts[0], /display notification "⏳ 写登录方案 等待审核(方案)" with title "maestro"/);
|
||
store.close();
|
||
});
|
||
|
||
test('notify:exec_review 且任一复审 verdict=reject → 文案改「复审建议拒绝」', () => {
|
||
const { store, projectId } = setup();
|
||
const t = store.createTask({ projectId, title: '高危改动', complexity: 'easy' });
|
||
store.transition(t.id, 'queued');
|
||
store.transition(t.id, 'executing');
|
||
store.setResult(t.id, { ...emptyResult, securityVerdict: 'reject' });
|
||
const { scripts } = capture(store);
|
||
store.transition(t.id, 'exec_review');
|
||
assert.equal(scripts.length, 1);
|
||
assert.match(scripts[0], /⚠ 高危改动 复审建议拒绝/);
|
||
store.close();
|
||
});
|
||
|
||
test('notify:exec_review 复审均通过 →「等待审核(PR)」', () => {
|
||
const { store, projectId } = setup();
|
||
const t = store.createTask({ projectId, title: '普通改动', complexity: 'easy' });
|
||
store.transition(t.id, 'queued');
|
||
store.transition(t.id, 'executing');
|
||
store.setResult(t.id, { ...emptyResult, verdict: 'approve', securityVerdict: 'approve' });
|
||
const { scripts } = capture(store);
|
||
store.transition(t.id, 'exec_review');
|
||
assert.equal(scripts.length, 1);
|
||
assert.match(scripts[0], /⏳ 普通改动 等待审核(PR)/);
|
||
store.close();
|
||
});
|
||
|
||
test('notify:needs_attention →「连续失败需人工」', () => {
|
||
const { store, projectId } = setup();
|
||
const t = store.createTask({ projectId, title: '坏任务', complexity: 'easy' });
|
||
store.transition(t.id, 'queued');
|
||
store.transition(t.id, 'executing');
|
||
store.transition(t.id, 'failed');
|
||
const { scripts } = capture(store);
|
||
store.transition(t.id, 'needs_attention');
|
||
assert.equal(scripts.length, 1);
|
||
assert.match(scripts[0], /❌ 坏任务 连续失败需人工/);
|
||
store.close();
|
||
});
|
||
|
||
test('notify:approval.granted 且 gate=exec →「已合并完成」;plan/spec 闸通过不通知', () => {
|
||
const { store, projectId } = setup();
|
||
// spec 闸 accept:不触发"已合并完成"
|
||
const m = store.createTask({ projectId, title: '方案任务', complexity: 'medium' });
|
||
store.setSpec(m.id, 'spec');
|
||
store.transition(m.id, 'spec_review');
|
||
// exec 闸 accept:触发
|
||
const e = store.createTask({ projectId, title: '执行任务', complexity: 'easy' });
|
||
store.transition(e.id, 'queued');
|
||
store.transition(e.id, 'executing');
|
||
store.setResult(e.id, { ...emptyResult });
|
||
store.transition(e.id, 'exec_review');
|
||
|
||
const { scripts } = capture(store);
|
||
store.decide(m.id, 'accept', 'user'); // spec 闸 → 无"合并完成"通知
|
||
store.decide(e.id, 'accept', 'user'); // exec 闸 → 通知
|
||
const merged = scripts.filter((s) => s.includes('已合并完成'));
|
||
assert.equal(merged.length, 1);
|
||
assert.match(merged[0], /✅ 执行任务 已合并完成/);
|
||
store.close();
|
||
});
|
||
|
||
test('notify:事件过滤——ready/queued/executing 等普通流转不通知', () => {
|
||
const { store, projectId } = setup();
|
||
const t = store.createTask({ projectId, title: '安静任务', complexity: 'easy' });
|
||
const { scripts } = capture(store);
|
||
store.transition(t.id, 'queued');
|
||
store.transition(t.id, 'executing');
|
||
store.transition(t.id, 'failed');
|
||
store.transition(t.id, 'queued');
|
||
assert.equal(scripts.length, 0);
|
||
store.close();
|
||
});
|
||
|
||
test('notify:同任务同类型 60s 抑制;超窗后再发;不同任务不互相抑制', () => {
|
||
const { store, projectId } = setup();
|
||
const t = store.createTask({ projectId, title: '反复任务', complexity: 'medium' });
|
||
const t2 = store.createTask({ projectId, title: '另一个', complexity: 'medium' });
|
||
let fakeNow = 1_000_000;
|
||
const { scripts } = capture(store, () => fakeNow);
|
||
|
||
store.setSpec(t.id, 's1');
|
||
store.transition(t.id, 'spec_review'); // 第 1 次:发
|
||
assert.equal(scripts.length, 1);
|
||
|
||
store.decide(t.id, 'reject', 'user', '再改改'); // 回 speccing(approval.rejected 不通知)
|
||
fakeNow += 30_000;
|
||
store.transition(t.id, 'spec_review'); // 30s 内同类型:抑制
|
||
assert.equal(scripts.length, 1);
|
||
|
||
store.setSpec(t2.id, 's2');
|
||
store.transition(t2.id, 'spec_review'); // 不同任务:不受抑制
|
||
assert.equal(scripts.length, 2);
|
||
|
||
store.decide(t.id, 'reject', 'user', '还得改');
|
||
fakeNow += NOTIFY_SUPPRESS_MS; // 距上次该任务通知已 >60s
|
||
store.transition(t.id, 'spec_review');
|
||
assert.equal(scripts.length, 3);
|
||
store.close();
|
||
});
|
||
|
||
test('notify:取消订阅后不再收到通知', () => {
|
||
const { store, projectId } = setup();
|
||
const t = store.createTask({ projectId, title: '退订', complexity: 'medium' });
|
||
const { scripts, stop } = capture(store);
|
||
stop();
|
||
store.setSpec(t.id, 's');
|
||
store.transition(t.id, 'spec_review');
|
||
assert.equal(scripts.length, 0);
|
||
store.close();
|
||
});
|
||
|
||
test('notify:MAESTRO_NOTIFY=0 时 startNotifier 不启用', () => {
|
||
const { store } = setup();
|
||
const prev = process.env.MAESTRO_NOTIFY;
|
||
process.env.MAESTRO_NOTIFY = '0';
|
||
try {
|
||
assert.equal(startNotifier(store, { log: noopLog }), null);
|
||
} finally {
|
||
if (prev === undefined) delete process.env.MAESTRO_NOTIFY;
|
||
else process.env.MAESTRO_NOTIFY = prev;
|
||
store.close();
|
||
}
|
||
});
|
||
|
||
test('sanitizeForOsascript:转义引号反斜杠、去换行、截断 80 字符', () => {
|
||
assert.equal(sanitizeForOsascript('say "hi" \\ ok'), 'say \\"hi\\" \\\\ ok');
|
||
assert.equal(sanitizeForOsascript('一行\n两行\r\n三行'), '一行 两行 三行');
|
||
const long = 'x'.repeat(200);
|
||
const cut = sanitizeForOsascript(long);
|
||
assert.equal(cut.length, 80);
|
||
assert.ok(cut.endsWith('…'));
|
||
});
|