feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
P0 安全与工程基线(止血): - .npmrc 移除硬编码 Gitea npm 凭据,改为 GITEA_NPM_AUTH 环境变量注入(已验证未设变量时 401) - 修复 typecheck 空操作缺陷:solution-style 根 tsconfig 改为双工程真检查(node + web), pre-commit 与 CI 门禁恢复拦截能力 - 修复 4 处 v0.4.1 遗留类型错误:confirmation-hook.test 枚举名 FILE_SYSTEM→FILESYSTEM、 agent.ts VALIDATION 事件 severity 类型谓词收窄、ContextMenu.tsx 导出 attachments 类型 - 补装 v0.4.1 声明但未安装的 node-html-parser 依赖 P1 逻辑缺陷修复(跨模块边界): - ConfirmationHook 会话隔离:rememberedDecisions 与 pendingConfirmations 按 sessionId 隔离, abortSession 只清本会话 pending(修复 A 会话中断误杀 B 会话确认、拒绝记忆跨会话污染) - SubAgent 可观测性:orchestrator 六个事件此前全项目零消费者,现接入 ① subagent:event 生命周期广播(AgentMonitor 新增 SubAgent 状态区) ② SubEngine 流事件独立 TRACE 录制(sessionId=taskId 的 JSONL 文件) - main.ts 启动链路异常兜底:初始化失败时记录日志 + 系统错误对话框 + 退出(原为白屏挂起) P2 工程强化: - CI:typecheck 双工程真检查;electron-test 从 experimental(continue-on-error)转正为阻塞门禁; GITEA_NPM_AUTH secret 注入说明 - 渲染 bundle 代码分割:单 2630KB chunk 拆为 main 557KB + vendor-react/mui/markdown/icons (业务代码变更不再使 vendor 缓存失效) - database 建表 mcp_servers CHECK 直接含 streamable-http(新库不再依赖迁移 6 立即重建) P3 功能补全: - DeepSeek 余额显示:新增 llm:getBalance IPC + LLMSettings 余额卡片(复用适配器原死代码 getBalance) - FTS5 会话内容搜索:messages_fts 虚表 + INSERT/UPDATE/DELETE 触发器实时同步 + 存量库 rebuild 迁移 + sessions:searchContent IPC + Sidebar 搜索框标题∪内容联合搜索 (短语转义防 FTS 运算符注入,按会话聚合展示 snippet) - 审计日志导出:audit:export IPC(JSONL / CSV RFC 4180 转义)+ LogsSettings 导出按钮 文档一致性大扫除: - README:工具数统一为 28(原 26/27/30 三口径)、handlers.ts→ipc/、录制事件名更正、 删除虚构的审计导出/归档宣称与 Schema 虚构字段、MCP 三种传输、配置 key 更正、 项目结构树对齐实际(settings 10 文件/lib 6 文件/react-virtuoso)、clone 地址改为 Gitea、 新增 GITEA_NPM_AUTH 配置说明、测试数 207 - 架构/构建指南/UI UX/IR 标准 4 份 HTML 设计文档同步修正(工具数、表数 10、 磁盘文件 2 个现状注记、ipc/*.ts 路径) - eslint.config.js 与开发规范.md 注释对齐零容忍基线与 better-sqlite3 选型 测试: 199→207 用例(新增 ConfirmationHook 跨会话隔离 5 用例 + FTS5 搜索/审计导出 8 用例) 验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 207 全过 / build 成功
This commit is contained in:
+253
-2
@@ -17,6 +17,7 @@ import { broadcast } from './context';
|
||||
import type { MetonaMessage, MetonaStreamEvent, MetonaError } from '../harness/types';
|
||||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||||
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
|
||||
import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
|
||||
import log from 'electron-log';
|
||||
|
||||
/** 单会话的 text_delta 节流状态 */
|
||||
@@ -511,8 +512,12 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
|
||||
|
||||
// v0.4.1: 推送验证结果到前端 — 只推送 warning/error 级(info 级为噪声)
|
||||
// 类型谓词收窄 severity,确保与 MetonaValidationPayload.issues 的类型一致
|
||||
const visibleIssues = validation.issues
|
||||
.filter((i) => i.severity === 'warning' || i.severity === 'error')
|
||||
.filter(
|
||||
(i): i is typeof i & { severity: 'warning' | 'error' } =>
|
||||
i.severity === 'warning' || i.severity === 'error',
|
||||
)
|
||||
.slice(0, 5);
|
||||
if (visibleIssues.length > 0) {
|
||||
broadcast('agent:streamEvent', {
|
||||
@@ -683,6 +688,27 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
},
|
||||
);
|
||||
|
||||
// ===== v0.5.0: DeepSeek 余额查询(复用 DeepSeekAdapter.getBalance,原为死代码) =====
|
||||
ipcMain.handle('llm:getBalance', async () => {
|
||||
try {
|
||||
const adapter = agentEngineManager.getAdapter();
|
||||
if (!(adapter instanceof DeepSeekAdapter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Balance query is only supported for the DeepSeek provider',
|
||||
};
|
||||
}
|
||||
const balance = await adapter.getBalance();
|
||||
if (!balance) {
|
||||
return { success: false, error: '余额查询失败(API Key 无效或网络错误)' };
|
||||
}
|
||||
return { success: true, data: balance };
|
||||
} catch (error) {
|
||||
log.warn('[AGENT] getBalance failed:', (error as Error).message);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 中断会话 =====
|
||||
|
||||
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
||||
@@ -693,7 +719,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
|
||||
await agentEngineManager.waitForAbort(sessionId);
|
||||
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
||||
confirmationHook.clearPending();
|
||||
// v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
|
||||
confirmationHook.clearPending(sessionId);
|
||||
|
||||
// TOOL 层:记录中断
|
||||
auditService.log({
|
||||
@@ -707,4 +734,228 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ===== v0.5.0: SubAgent 可观测性 =====
|
||||
// 1) 生命周期事件广播给前端(AgentMonitor 的 SubAgent 状态区)
|
||||
// 2) SubEngine 流事件录制到独立 TRACE 文件(sessionId = taskId),不污染父会话的流
|
||||
// 此前 orchestrator 的 6 个事件全项目零消费者,SubAgent 执行过程对 UI 与 TRACE 完全不可见
|
||||
|
||||
/** SubAgent 元信息(description/depth,供完成/失败事件广播时补全载荷) */
|
||||
const subMeta = new Map<string, { description: string; depth: number }>();
|
||||
/** SubAgent 迭代录制状态(THINKING 驱动新迭代,text 累积供 llm_response) */
|
||||
interface SubTraceState {
|
||||
iteration: number;
|
||||
startedAt: number;
|
||||
text: string;
|
||||
usage: { input: number; output: number; total: number };
|
||||
responded: boolean;
|
||||
}
|
||||
const subTraces = new Map<string, SubTraceState>();
|
||||
|
||||
const finishSubTrace = (
|
||||
taskId: string,
|
||||
reason: string,
|
||||
durationMs: number,
|
||||
iterations: number,
|
||||
): void => {
|
||||
sessionRecorder.stopRecording(taskId, {
|
||||
totalIterations: iterations,
|
||||
totalTokens: 0,
|
||||
durationMs,
|
||||
terminationReason: reason,
|
||||
});
|
||||
subTraces.delete(taskId);
|
||||
subMeta.delete(taskId);
|
||||
};
|
||||
|
||||
orchestrator.on(
|
||||
'taskDelegated',
|
||||
(d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
|
||||
broadcast('subagent:event', {
|
||||
taskId: d.taskId,
|
||||
parentSessionId: d.parentSessionId,
|
||||
description: d.description,
|
||||
status: 'delegated',
|
||||
depth: d.depth,
|
||||
});
|
||||
subMeta.set(d.taskId, { description: d.description, depth: d.depth });
|
||||
sessionRecorder.startRecording(d.taskId);
|
||||
},
|
||||
);
|
||||
|
||||
orchestrator.on(
|
||||
'taskStarted',
|
||||
(d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
|
||||
broadcast('subagent:event', {
|
||||
taskId: d.taskId,
|
||||
parentSessionId: d.parentSessionId,
|
||||
description: d.description,
|
||||
status: 'running',
|
||||
depth: d.depth,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
orchestrator.on(
|
||||
'taskCompleted',
|
||||
(r: {
|
||||
taskId: string;
|
||||
parentSessionId: string;
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
iterations: number;
|
||||
}) => {
|
||||
const meta = subMeta.get(r.taskId);
|
||||
broadcast('subagent:event', {
|
||||
taskId: r.taskId,
|
||||
parentSessionId: r.parentSessionId,
|
||||
description: meta?.description ?? '',
|
||||
status: r.success ? 'completed' : 'error',
|
||||
depth: meta?.depth ?? 1,
|
||||
durationMs: r.durationMs,
|
||||
iterations: r.iterations,
|
||||
});
|
||||
finishSubTrace(r.taskId, r.success ? 'completed' : 'error', r.durationMs, r.iterations);
|
||||
},
|
||||
);
|
||||
|
||||
orchestrator.on(
|
||||
'taskError',
|
||||
(d: { taskId: string; parentSessionId: string; description: string; error: string }) => {
|
||||
const meta = subMeta.get(d.taskId);
|
||||
broadcast('subagent:event', {
|
||||
taskId: d.taskId,
|
||||
parentSessionId: d.parentSessionId,
|
||||
description: d.description ?? meta?.description ?? '',
|
||||
status: 'error',
|
||||
depth: meta?.depth ?? 1,
|
||||
error: d.error,
|
||||
});
|
||||
finishSubTrace(d.taskId, 'error', 0, 0);
|
||||
},
|
||||
);
|
||||
|
||||
// SubEngine 状态事件 → TRACE 迭代录制(与主管道相同的事件形状,sessionId = taskId)
|
||||
orchestrator.on(
|
||||
'subStateChange',
|
||||
({
|
||||
taskId,
|
||||
data,
|
||||
}: {
|
||||
taskId: string;
|
||||
data: { state?: string; current?: string; iteration?: number };
|
||||
}) => {
|
||||
const st = subTraces.get(taskId);
|
||||
const stateValue = data.state ?? data.current ?? '';
|
||||
|
||||
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
|
||||
if (
|
||||
stateValue === 'THINKING' &&
|
||||
data.iteration != null &&
|
||||
(!st || st.iteration !== data.iteration)
|
||||
) {
|
||||
if (st && !st.responded) {
|
||||
sessionRecorder.recordLLMResponse({
|
||||
sessionId: taskId,
|
||||
iteration: st.iteration,
|
||||
content: st.text,
|
||||
finishReason: 'stop',
|
||||
tokenUsage: st.usage,
|
||||
});
|
||||
sessionRecorder.recordIterationEnd(taskId, {
|
||||
iteration: st.iteration,
|
||||
durationMs: Date.now() - st.startedAt,
|
||||
});
|
||||
}
|
||||
subTraces.set(taskId, {
|
||||
iteration: data.iteration,
|
||||
startedAt: Date.now(),
|
||||
text: '',
|
||||
usage: { input: 0, output: 0, total: 0 },
|
||||
responded: false,
|
||||
});
|
||||
sessionRecorder.recordIterationStart(taskId, data.iteration);
|
||||
const provider = configService.get<string>('llm.provider') ?? '';
|
||||
const model = configService.get<string>('llm.model') ?? '';
|
||||
sessionRecorder.recordLLMRequest({
|
||||
sessionId: taskId,
|
||||
iteration: data.iteration,
|
||||
provider,
|
||||
model,
|
||||
messageCount: data.iteration + 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TERMINATED → 补记最终迭代(正常流程只在下一轮 THINKING 补记,最终轮无后续)
|
||||
if (stateValue === 'TERMINATED' && st) {
|
||||
if (!st.responded) {
|
||||
sessionRecorder.recordLLMResponse({
|
||||
sessionId: taskId,
|
||||
iteration: st.iteration,
|
||||
content: st.text,
|
||||
finishReason: 'stop',
|
||||
tokenUsage: st.usage,
|
||||
});
|
||||
}
|
||||
sessionRecorder.recordIterationEnd(taskId, {
|
||||
iteration: st.iteration,
|
||||
durationMs: Date.now() - st.startedAt,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// SubEngine 流事件 → TRACE 内容录制(文本累积 + 工具调用/结果)
|
||||
orchestrator.on(
|
||||
'subStreamEvent',
|
||||
({ taskId, event }: { taskId: string; event: MetonaStreamEvent }) => {
|
||||
const st = subTraces.get(taskId);
|
||||
if (!st) return;
|
||||
|
||||
switch (event.type) {
|
||||
case MetonaStreamEventType.TEXT_DELTA:
|
||||
if (event.delta) st.text += event.delta;
|
||||
break;
|
||||
case MetonaStreamEventType.USAGE:
|
||||
if (event.usage) {
|
||||
st.usage = {
|
||||
input: event.usage.inputTokens ?? 0,
|
||||
output: event.usage.outputTokens ?? 0,
|
||||
total: event.usage.totalTokens ?? 0,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case MetonaStreamEventType.TOOL_CALL_COMPLETE:
|
||||
if (event.toolCall) {
|
||||
sessionRecorder.recordToolCall({
|
||||
sessionId: taskId,
|
||||
iteration: event.iteration,
|
||||
toolName: event.toolCall.name,
|
||||
args: event.toolCall.args,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case MetonaStreamEventType.TOOL_RESULT:
|
||||
if (event.toolResult) {
|
||||
const resultPreview =
|
||||
typeof event.toolResult.result === 'string'
|
||||
? event.toolResult.result
|
||||
: JSON.stringify(event.toolResult.result);
|
||||
sessionRecorder.recordToolResult({
|
||||
sessionId: taskId,
|
||||
iteration: event.iteration,
|
||||
toolName: event.toolResult.toolName,
|
||||
success: event.toolResult.success,
|
||||
durationMs: event.toolResult.durationMs ?? 0,
|
||||
resultPreview,
|
||||
error: event.toolResult.error,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user