feat: implement Phase 7 Execution Protection Layer (ZY-LANGDRIVE-S1)
L1 Frontend Isolation: - docs/js/execution-guard.js: UI lock/unlock, execution replay polling, browser guard - docs/css/execution-guard.css: Execution lock styles, progress bar, log entries L2 API Execution Interception: - backend/api-server/middleware/execution-lock.js: Execution context lock, cancel interception - backend/api-server/routes/execution.js: Status query, cancel/delete rejection (403) L3 Execution Atomicity: - backend/api-server/services/checkpoint.js: Checkpoint system with rollback - backend/api-server/services/atomic-executor.js: Atomic execution wrapper - backend/api-server/services/execution-watchdog.js: 15min timeout, deadlock prevention Integration: - Updated server.js to v3.0.0: execution routes, cancellation middleware, watchdog startup Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/6ee737eb-684b-44b8-aeaa-f73eb7c6f9c2
This commit is contained in:
parent
5e8bb969a4
commit
e0880deb46
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* 执行锁中间件 · L2 API 执行拦截层
|
||||
*
|
||||
* 当一个操作开始执行时:
|
||||
* 1. 为该操作创建一个执行上下文(executionId)
|
||||
* 2. 锁定相关资源(防止并发冲突)
|
||||
* 3. 拒绝一切 cancel/abort/rollback 请求
|
||||
* 4. 执行完成后自动释放锁
|
||||
*
|
||||
* 同一个开发者不能同时执行多个写入操作。
|
||||
* 版权:国作登字-2026-A-00037559
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var crypto = require('crypto');
|
||||
|
||||
// 内存中的执行锁表
|
||||
var activeLocks = new Map();
|
||||
|
||||
function generateExecutionId() {
|
||||
return 'exec-' + Date.now() + '-' + crypto.randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取执行锁表引用(供看门狗使用)
|
||||
*/
|
||||
function getActiveLocks() {
|
||||
return activeLocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建执行锁
|
||||
* @param {string} devId - 开发者编号
|
||||
* @param {string} operation - 操作描述
|
||||
* @returns {Object} { success, executionId, lock } 或 { success: false, reply }
|
||||
*/
|
||||
function acquireLock(devId, operation) {
|
||||
if (activeLocks.has(devId)) {
|
||||
var existing = activeLocks.get(devId);
|
||||
if (existing.state === 'running') {
|
||||
return {
|
||||
success: false,
|
||||
reply: '⏳ 你有一个操作正在执行中(' + existing.operation +
|
||||
'),请等待完成。\n\n系统不允许同时执行多个操作——这是为了保护数据一致性。'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var executionId = generateExecutionId();
|
||||
var lock = {
|
||||
executionId: executionId,
|
||||
devId: devId,
|
||||
operation: operation,
|
||||
startTime: Date.now(),
|
||||
state: 'running',
|
||||
steps: [],
|
||||
checkpoints: [],
|
||||
warned: false,
|
||||
completedSteps: 0,
|
||||
totalSteps: 0,
|
||||
result: null,
|
||||
error: null
|
||||
};
|
||||
|
||||
activeLocks.set(devId, lock);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
executionId: executionId,
|
||||
lock: lock
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放执行锁
|
||||
*/
|
||||
function releaseLock(devId) {
|
||||
activeLocks.delete(devId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取执行状态(通过 executionId)
|
||||
*/
|
||||
function getExecutionStatus(executionId) {
|
||||
for (var entry of activeLocks) {
|
||||
if (entry[1].executionId === executionId) {
|
||||
return entry[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录执行步骤(用于事后回放)
|
||||
*/
|
||||
function logStep(devId, step) {
|
||||
var lock = activeLocks.get(devId);
|
||||
if (lock) {
|
||||
lock.steps.push({
|
||||
message: step.message,
|
||||
success: step.success,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
if (step.success) {
|
||||
lock.completedSteps++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存检查点(用于失败时回滚)
|
||||
*/
|
||||
function saveCheckpoint(devId, checkpoint) {
|
||||
var lock = activeLocks.get(devId);
|
||||
if (lock) {
|
||||
lock.checkpoints.push({
|
||||
id: checkpoint.id,
|
||||
label: checkpoint.label,
|
||||
data: checkpoint.data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消拦截中间件
|
||||
* 拦截所有试图取消执行中操作的请求
|
||||
*/
|
||||
function blockCancellation(req, res, next) {
|
||||
var devId = req.user ? req.user.devId : null;
|
||||
if (!devId) return next();
|
||||
|
||||
var isCancelAttempt =
|
||||
req.path.includes('/cancel') ||
|
||||
req.path.includes('/abort') ||
|
||||
req.path.includes('/rollback');
|
||||
|
||||
var isDeleteOnLocked = req.method === 'DELETE' && activeLocks.has(devId);
|
||||
|
||||
if (isCancelAttempt || isDeleteOnLocked) {
|
||||
var lock = activeLocks.get(devId);
|
||||
if (lock && lock.state === 'running') {
|
||||
return res.status(423).json({
|
||||
error: true,
|
||||
code: 'EXECUTION_LOCKED',
|
||||
locked: true,
|
||||
executionId: lock.executionId,
|
||||
reply: '🔒 操作「' + lock.operation + '」正在执行中,不可取消。\n\n' +
|
||||
'系统已接收你的指令并开始执行。这条执行链路涉及多个自动化流程的联动,' +
|
||||
'中途中断会导致数据不一致和级联故障。\n\n' +
|
||||
'⏳ 请等待执行完成。完成后你会看到完整的执行结果。\n\n' +
|
||||
'如果你对结果不满意,可以说「前面的没想好,我们重新来」——' +
|
||||
'系统会在已有结果的基础上重新迭代,而不是撤回已执行的操作。'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
acquireLock: acquireLock,
|
||||
releaseLock: releaseLock,
|
||||
getExecutionStatus: getExecutionStatus,
|
||||
getActiveLocks: getActiveLocks,
|
||||
logStep: logStep,
|
||||
saveCheckpoint: saveCheckpoint,
|
||||
blockCancellation: blockCancellation
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* 执行状态查询路由 · L2 API 层
|
||||
*
|
||||
* GET /api/execution/:executionId/status — 前端轮询执行状态
|
||||
* POST /api/execution/:executionId/cancel — 明确拒绝取消(403)
|
||||
* DELETE /api/execution/:executionId — 明确拒绝删除(403)
|
||||
*
|
||||
* 注意:没有取消接口。这不是遗漏,是设计。
|
||||
* 版权:国作登字-2026-A-00037559
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var executionLock = require('../middleware/execution-lock');
|
||||
var authMiddleware = require('../middleware/auth');
|
||||
|
||||
router.use(authMiddleware.requireAuth);
|
||||
|
||||
// 查询执行状态(前端轮询用)
|
||||
router.get('/:executionId/status', function(req, res) {
|
||||
var status = executionLock.getExecutionStatus(req.params.executionId);
|
||||
|
||||
if (!status) {
|
||||
return res.json({
|
||||
state: 'not_found',
|
||||
reply: '未找到该执行记录。可能已经完成。'
|
||||
});
|
||||
}
|
||||
|
||||
// 计算进度百分比
|
||||
var progress = 5;
|
||||
if (status.totalSteps > 0) {
|
||||
progress = Math.min(95, Math.round((status.completedSteps / status.totalSteps) * 100));
|
||||
} else if (status.steps.length > 0) {
|
||||
progress = Math.min(95, status.steps.length * 15);
|
||||
}
|
||||
|
||||
var afterIdx = parseInt(req.query.after, 10) || 0;
|
||||
|
||||
res.json({
|
||||
executionId: status.executionId,
|
||||
state: status.state,
|
||||
operation: status.operation,
|
||||
progress: progress,
|
||||
currentStep: status.steps.length > 0
|
||||
? status.steps[status.steps.length - 1].message
|
||||
: '初始化执行环境…',
|
||||
completedSteps: status.steps.filter(function(s) { return s.success; }).map(function(s) {
|
||||
return { message: s.message, timestamp: s.timestamp };
|
||||
}),
|
||||
newLogs: status.steps.slice(afterIdx),
|
||||
startTime: new Date(status.startTime).toISOString(),
|
||||
elapsed: Date.now() - status.startTime,
|
||||
result: status.state === 'completed' ? status.result : undefined,
|
||||
error: status.state === 'failed' ? status.error : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// 明确拒绝取消执行的请求
|
||||
router.post('/:executionId/cancel', function(_req, res) {
|
||||
res.status(403).json({
|
||||
error: true,
|
||||
code: 'CANCEL_FORBIDDEN',
|
||||
reply: '🔒 执行中的操作不可取消。\n\n' +
|
||||
'系统已开始执行,90+ 个自动化流程正在联动。' +
|
||||
'中途中断会导致数据不一致和级联故障。\n\n' +
|
||||
'请等待执行完成。如果结果不理想,你可以说:\n' +
|
||||
'「前面的我没想好,我们重新来」\n' +
|
||||
'系统会在已有基础上重新迭代。'
|
||||
});
|
||||
});
|
||||
|
||||
// 明确拒绝删除执行记录
|
||||
router.delete('/:executionId', function(_req, res) {
|
||||
res.status(403).json({
|
||||
error: true,
|
||||
code: 'DELETE_FORBIDDEN',
|
||||
reply: '🔒 不可删除执行记录。所有执行都是永久记录。'
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* 🔗 光湖后端中间层 · 通道B 入口
|
||||
*
|
||||
* 实时调用 Notion API / GitHub API,为前端提供数据代理。
|
||||
* 包含:读取层 + 写入层 + 意图路由 + 权限沙箱 + 认知引导
|
||||
* 包含:读取层 + 写入层 + 意图路由 + 权限沙箱 + 认知引导 + 执行保护
|
||||
* 只监听 127.0.0.1,通过 Nginx 反向代理对外暴露。
|
||||
*
|
||||
* 版权:国作登字-2026-A-00037559
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const executionLock = require('./middleware/execution-lock');
|
||||
const watchdog = require('./services/execution-watchdog');
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -27,6 +29,9 @@ app.use(cors({
|
|||
|
||||
app.use(express.json());
|
||||
|
||||
// 执行锁取消拦截(全局中间件,在所有路由之前)
|
||||
app.use(executionLock.blockCancellation);
|
||||
|
||||
// 读取类路由(公开,无需认证)
|
||||
app.use('/api', require('./routes/health'));
|
||||
app.use('/api', require('./routes/dev'));
|
||||
|
|
@ -40,15 +45,18 @@ app.use('/api', require('./routes/write'));
|
|||
// 认知引导 + 工具列表路由
|
||||
app.use('/api', require('./routes/onboarding'));
|
||||
|
||||
// 执行状态查询路由(需认证)
|
||||
app.use('/api/execution', require('./routes/execution'));
|
||||
|
||||
// 根路由
|
||||
app.get('/', function(_req, res) {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'guanghu-api-server',
|
||||
version: '2.0.0',
|
||||
version: '3.0.0',
|
||||
channel: 'B',
|
||||
description: '光湖后端中间层 · 语言驱动操作系统',
|
||||
capabilities: ['read', 'write', 'intent-routing', 'permission-sandbox', 'onboarding']
|
||||
capabilities: ['read', 'write', 'intent-routing', 'permission-sandbox', 'onboarding', 'execution-guard']
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -56,6 +64,9 @@ const PORT = process.env.PORT || 3001;
|
|||
app.listen(PORT, '127.0.0.1', function() {
|
||||
console.log('🔗 光湖后端中间层启动 · 端口 ' + PORT);
|
||||
console.log(' 通道B · 语言驱动操作系统');
|
||||
console.log(' 能力:读取 + 写入 + 意图路由 + 权限沙箱 + 认知引导');
|
||||
console.log(' 能力:读取 + 写入 + 意图路由 + 权限沙箱 + 认知引导 + 执行保护');
|
||||
console.log(' 监听地址:127.0.0.1:' + PORT + '(仅本机访问)');
|
||||
|
||||
// 启动执行看门狗
|
||||
watchdog.startWatchdog();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* 原子执行器 · L3 执行原子性保障
|
||||
*
|
||||
* 包装所有写入操作,确保:
|
||||
* 1. 执行前保存检查点
|
||||
* 2. 执行中记录每一步
|
||||
* 3. 执行失败自动回滚
|
||||
* 4. 执行完成释放锁
|
||||
*
|
||||
* 使用方式:
|
||||
* var executor = new AtomicExecutor(devId);
|
||||
* var result = await executor.execute('创建工单', async function(ctx) {
|
||||
* await ctx.step('写入Notion', async function() { ... });
|
||||
* await ctx.step('触发Workflow', async function() { ... });
|
||||
* });
|
||||
*
|
||||
* 版权:国作登字-2026-A-00037559
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var executionLock = require('../middleware/execution-lock');
|
||||
var checkpoint = require('./checkpoint');
|
||||
var CheckpointManager = checkpoint.CheckpointManager;
|
||||
|
||||
/**
|
||||
* 原子执行器
|
||||
* @param {string} devId - 开发者编号
|
||||
*/
|
||||
function AtomicExecutor(devId) {
|
||||
this.devId = devId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行操作(原子性保障)
|
||||
* @param {string} operationName - 操作描述
|
||||
* @param {Function} fn - 异步执行函数,接收 ctx 参数
|
||||
* @returns {Object} 执行结果
|
||||
*/
|
||||
AtomicExecutor.prototype.execute = async function(operationName, fn) {
|
||||
// 1. 获取执行锁
|
||||
var lockResult = executionLock.acquireLock(this.devId, operationName);
|
||||
if (!lockResult.success) {
|
||||
return lockResult;
|
||||
}
|
||||
|
||||
var executionId = lockResult.executionId;
|
||||
var lock = lockResult.lock;
|
||||
var devId = this.devId;
|
||||
var cpManager = new CheckpointManager(devId, executionId);
|
||||
|
||||
// 2. 保存初始检查点
|
||||
await cpManager.save('执行前状态', {
|
||||
type: 'initial',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 3. 构建执行上下文
|
||||
var ctx = {
|
||||
executionId: executionId,
|
||||
|
||||
/**
|
||||
* 执行单步操作
|
||||
* @param {string} stepName - 步骤名称
|
||||
* @param {Function} stepFn - 步骤执行函数
|
||||
* @returns {*} 步骤执行结果
|
||||
*/
|
||||
step: async function(stepName, stepFn) {
|
||||
executionLock.logStep(devId, {
|
||||
message: '⏳ ' + stepName + '...',
|
||||
success: false
|
||||
});
|
||||
|
||||
try {
|
||||
var result = await stepFn();
|
||||
|
||||
executionLock.logStep(devId, {
|
||||
message: '✅ ' + stepName,
|
||||
success: true
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
executionLock.logStep(devId, {
|
||||
message: '❌ ' + stepName + ': ' + e.message,
|
||||
success: false
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 保存检查点
|
||||
* @param {string} label - 检查点标签
|
||||
* @param {Object} data - 快照数据
|
||||
* @returns {string} 检查点ID
|
||||
*/
|
||||
checkpoint: async function(label, data) {
|
||||
return await cpManager.save(label, data);
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 执行主逻辑
|
||||
try {
|
||||
var result = await fn(ctx);
|
||||
|
||||
// 5. 成功:更新锁状态
|
||||
lock.state = 'completed';
|
||||
lock.result = result;
|
||||
|
||||
// 延迟释放锁(给前端时间拉取最终状态)
|
||||
setTimeout(function() { executionLock.releaseLock(devId); }, 10000);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
executionId: executionId,
|
||||
result: result,
|
||||
reply: result && result.reply ? result.reply : '✅ 操作「' + operationName + '」执行完成。'
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
// 6. 失败:自动回滚
|
||||
lock.state = 'rolling_back';
|
||||
executionLock.logStep(devId, {
|
||||
message: '🔄 执行失败,正在自动回滚...',
|
||||
success: false
|
||||
});
|
||||
|
||||
try {
|
||||
await cpManager.rollbackTo('cp-1');
|
||||
executionLock.logStep(devId, {
|
||||
message: '✅ 已回滚到执行前状态',
|
||||
success: true
|
||||
});
|
||||
} catch (rollbackError) {
|
||||
executionLock.logStep(devId, {
|
||||
message: '⚠️ 回滚异常:' + rollbackError.message + ',已通知管理员',
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
lock.state = 'failed';
|
||||
lock.error = { message: e.message };
|
||||
|
||||
setTimeout(function() { executionLock.releaseLock(devId); }, 10000);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
executionId: executionId,
|
||||
error: e.message,
|
||||
reply: '❌ 操作「' + operationName + '」执行失败:' + e.message +
|
||||
'\n\n系统已自动回滚到执行前状态,没有数据被修改。'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { AtomicExecutor: AtomicExecutor };
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* 检查点系统 · L3 执行原子性保障
|
||||
*
|
||||
* 在执行链路的每个关键节点保存快照。
|
||||
* 如果后续步骤失败,自动回滚到最近的成功检查点。
|
||||
*
|
||||
* 检查点包含:
|
||||
* - Notion 数据库的变更前快照
|
||||
* - GitHub 操作前的 commit SHA
|
||||
* - 执行到哪一步
|
||||
*
|
||||
* 版权:国作登字-2026-A-00037559
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var executionLock = require('../middleware/execution-lock');
|
||||
|
||||
/**
|
||||
* 检查点管理器
|
||||
* @param {string} devId - 开发者编号
|
||||
* @param {string} executionId - 执行ID
|
||||
*/
|
||||
function CheckpointManager(devId, executionId) {
|
||||
this.devId = devId;
|
||||
this.executionId = executionId;
|
||||
this.checkpoints = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 在执行关键操作前保存检查点
|
||||
* @param {string} label - 检查点标签
|
||||
* @param {Object} snapshotData - 快照数据 { type, action, pageId, previousValues, ... }
|
||||
* @returns {string} 检查点ID
|
||||
*/
|
||||
CheckpointManager.prototype.save = async function(label, snapshotData) {
|
||||
var checkpoint = {
|
||||
id: 'cp-' + (this.checkpoints.length + 1),
|
||||
label: label,
|
||||
data: snapshotData || {},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.checkpoints.push(checkpoint);
|
||||
executionLock.saveCheckpoint(this.devId, checkpoint);
|
||||
|
||||
return checkpoint.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 回滚到指定检查点
|
||||
* 按逆序撤销检查点之后的所有操作
|
||||
* @param {string} checkpointId - 目标检查点 ID
|
||||
*/
|
||||
CheckpointManager.prototype.rollbackTo = async function(checkpointId) {
|
||||
var targetIndex = -1;
|
||||
for (var i = 0; i < this.checkpoints.length; i++) {
|
||||
if (this.checkpoints[i].id === checkpointId) {
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetIndex === -1) {
|
||||
throw new Error('检查点 ' + checkpointId + ' 不存在');
|
||||
}
|
||||
|
||||
// 从最后一个检查点开始逆序回滚
|
||||
var toRollback = this.checkpoints.slice(targetIndex + 1).reverse();
|
||||
|
||||
for (var j = 0; j < toRollback.length; j++) {
|
||||
var cp = toRollback[j];
|
||||
try {
|
||||
await this.rollbackCheckpoint(cp);
|
||||
} catch (e) {
|
||||
console.error('[Checkpoint] 回滚 ' + cp.id + ' 失败:', e.message);
|
||||
await this.escalateToAdmin(cp, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 截断检查点列表
|
||||
this.checkpoints = this.checkpoints.slice(0, targetIndex + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* 回滚单个检查点
|
||||
* @param {Object} checkpoint
|
||||
*/
|
||||
CheckpointManager.prototype.rollbackCheckpoint = async function(checkpoint) {
|
||||
var data = checkpoint.data;
|
||||
if (!data || !data.type) return;
|
||||
|
||||
var notionService, githubService;
|
||||
|
||||
switch (data.type) {
|
||||
case 'notion_write':
|
||||
notionService = require('./notion');
|
||||
if (data.action === 'create' && data.pageId && notionService.notion) {
|
||||
// 新建的页面 → 归档
|
||||
await notionService.notion.pages.update({
|
||||
page_id: data.pageId,
|
||||
archived: true
|
||||
});
|
||||
} else if (data.action === 'update' && data.pageId && data.previousValues && notionService.notion) {
|
||||
// 更新的页面 → 恢复原值
|
||||
await notionService.notion.pages.update({
|
||||
page_id: data.pageId,
|
||||
properties: data.previousValues
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'github_workflow':
|
||||
// Workflow 无法直接回滚,记录需要人工处理
|
||||
if (data.revertWorkflow) {
|
||||
githubService = require('./github');
|
||||
await githubService.triggerWorkflow(data.revertWorkflow, {
|
||||
original_run_id: String(data.runId || ''),
|
||||
reason: 'auto_rollback'
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'github_file':
|
||||
githubService = require('./github');
|
||||
if (data.previousSha) {
|
||||
// 文件存在之前的版本 → 恢复
|
||||
// 注意:这需要 GitHub API 的 update file 能力
|
||||
console.warn('[Checkpoint] GitHub 文件回滚需要人工介入: ' + data.path);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'initial':
|
||||
// 初始检查点,无需回滚操作
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[Checkpoint] 未知检查点类型: ' + data.type);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 回滚失败时上报管理员
|
||||
* @param {Object} checkpoint
|
||||
* @param {Error} error
|
||||
*/
|
||||
CheckpointManager.prototype.escalateToAdmin = async function(checkpoint, error) {
|
||||
try {
|
||||
var notionService = require('./notion');
|
||||
var dbConfig = require('../config/databases');
|
||||
|
||||
if (dbConfig.maintenanceLog && notionService.notion) {
|
||||
await notionService.writeToDB(dbConfig.maintenanceLog, {
|
||||
'标题': {
|
||||
title: [{ text: { content: '[紧急] 自动回滚失败 · ' + this.executionId } }]
|
||||
},
|
||||
'类型': { select: { name: '系统异常' } },
|
||||
'操作者': {
|
||||
rich_text: [{
|
||||
text: {
|
||||
content: '检查点 ' + checkpoint.id + ' (' + checkpoint.label +
|
||||
') 回滚失败: ' + error.message + '。需要冰朔人工介入。'
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Checkpoint] 上报管理员失败:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { CheckpointManager: CheckpointManager };
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* 执行看门狗 · 超时保护 + 死锁防护
|
||||
*
|
||||
* 定期检查执行锁是否超时。
|
||||
* 如果一个操作执行超过 MAX_EXECUTION_TIME,标记为超时并释放锁。
|
||||
* 这是防止死锁的兜底机制。
|
||||
*
|
||||
* 版权:国作登字-2026-A-00037559
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var executionLock = require('../middleware/execution-lock');
|
||||
|
||||
// 最大执行时间:15 分钟
|
||||
var MAX_EXECUTION_TIME = 15 * 60 * 1000;
|
||||
|
||||
// 警告时间:10 分钟
|
||||
var WARN_TIME = 10 * 60 * 1000;
|
||||
|
||||
// 检查间隔:30 秒
|
||||
var CHECK_INTERVAL = 30 * 1000;
|
||||
|
||||
var watchdogTimer = null;
|
||||
|
||||
/**
|
||||
* 启动看门狗
|
||||
* 定期扫描活跃锁表,检测超时操作
|
||||
*/
|
||||
function startWatchdog() {
|
||||
if (watchdogTimer) return;
|
||||
|
||||
watchdogTimer = setInterval(function() {
|
||||
var activeLocks = executionLock.getActiveLocks();
|
||||
var now = Date.now();
|
||||
|
||||
for (var entry of activeLocks) {
|
||||
var devId = entry[0];
|
||||
var lock = entry[1];
|
||||
|
||||
if (lock.state !== 'running') continue;
|
||||
|
||||
var elapsed = now - lock.startTime;
|
||||
|
||||
// 超时:强制释放
|
||||
if (elapsed > MAX_EXECUTION_TIME) {
|
||||
console.error(
|
||||
'[WATCHDOG] 执行超时: ' + lock.executionId +
|
||||
' (' + lock.operation + ') - ' + Math.round(elapsed / 1000) + 's'
|
||||
);
|
||||
|
||||
lock.state = 'timeout';
|
||||
lock.error = {
|
||||
message: '执行超时(超过 ' + Math.round(MAX_EXECUTION_TIME / 60000) + ' 分钟)'
|
||||
};
|
||||
|
||||
executionLock.logStep(devId, {
|
||||
message: '⏰ 执行超时,看门狗已强制释放锁',
|
||||
success: false
|
||||
});
|
||||
|
||||
// 延迟释放(给前端时间获取超时状态)
|
||||
(function(id) {
|
||||
setTimeout(function() { executionLock.releaseLock(id); }, 30000);
|
||||
})(devId);
|
||||
}
|
||||
// 警告
|
||||
else if (elapsed > WARN_TIME && !lock.warned) {
|
||||
console.warn(
|
||||
'[WATCHDOG] 执行时间较长: ' + lock.executionId +
|
||||
' (' + lock.operation + ') - ' + Math.round(elapsed / 1000) + 's'
|
||||
);
|
||||
lock.warned = true;
|
||||
}
|
||||
}
|
||||
}, CHECK_INTERVAL);
|
||||
|
||||
// 不阻止 Node.js 进程退出
|
||||
if (watchdogTimer.unref) {
|
||||
watchdogTimer.unref();
|
||||
}
|
||||
|
||||
console.log('[WATCHDOG] 执行看门狗已启动 · 检查间隔 ' + (CHECK_INTERVAL / 1000) + 's · 超时阈值 ' + (MAX_EXECUTION_TIME / 60000) + 'min');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止看门狗
|
||||
*/
|
||||
function stopWatchdog() {
|
||||
if (watchdogTimer) {
|
||||
clearInterval(watchdogTimer);
|
||||
watchdogTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startWatchdog: startWatchdog,
|
||||
stopWatchdog: stopWatchdog,
|
||||
MAX_EXECUTION_TIME: MAX_EXECUTION_TIME
|
||||
};
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* 执行保护样式 · L1 前端隔离
|
||||
*
|
||||
* 注意:没有 .cancel-button 类。这不是遗漏,是设计。
|
||||
* 版权:国作登字-2026-A-00037559
|
||||
*/
|
||||
|
||||
/* 执行中状态 · 输入框锁定 */
|
||||
.chat-input.execution-locked,
|
||||
#chat-input.execution-locked {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
background: #2a2a3a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.chat-input.execution-locked::placeholder,
|
||||
#chat-input.execution-locked::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 执行状态条 */
|
||||
.execution-status-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
animation: pulse-gentle 2s infinite;
|
||||
}
|
||||
|
||||
.execution-status-bar .exec-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.execution-status-bar .exec-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.execution-status-bar.blue {
|
||||
background: rgba(35, 131, 226, 0.08);
|
||||
color: #64c8ff;
|
||||
}
|
||||
|
||||
.execution-status-bar.green {
|
||||
background: rgba(15, 123, 108, 0.08);
|
||||
color: #0F7B6C;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.execution-status-bar.red {
|
||||
background: rgba(212, 76, 71, 0.08);
|
||||
color: #D44C47;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* 执行进度条 */
|
||||
.execution-progress {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.execution-progress-fill {
|
||||
height: 100%;
|
||||
background: #64c8ff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.execution-status-bar.green .execution-progress-fill {
|
||||
background: #0F7B6C;
|
||||
}
|
||||
|
||||
.execution-status-bar.red .execution-progress-fill {
|
||||
background: #D44C47;
|
||||
}
|
||||
|
||||
/* 执行日志容器 */
|
||||
.execution-log-container {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
margin: 4px 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 执行日志条目 */
|
||||
.execution-log-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
color: #b0b0c0;
|
||||
}
|
||||
|
||||
.execution-log-entry .log-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.execution-log-entry .log-msg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.execution-log-entry .log-time {
|
||||
flex-shrink: 0;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 温和脉冲动画 */
|
||||
@keyframes pulse-gentle {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* 注意:没有 .cancel-button 类。这不是遗漏。 */
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
/**
|
||||
* 执行保护 · 前端隔离层 (L1)
|
||||
*
|
||||
* 当开发者发出一条会触发系统操作的指令后:
|
||||
* 1. 输入框立即禁用(不能发新指令)
|
||||
* 2. 取消/撤回按钮不存在(从未渲染过,不是隐藏)
|
||||
* 3. 界面进入「执行回放」模式
|
||||
* 4. 执行完成后才解锁输入框
|
||||
*
|
||||
* 版权:国作登字-2026-A-00037559
|
||||
*/
|
||||
|
||||
/* global HOLOLAKE_ENV */
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
var API_BASE = (typeof HOLOLAKE_ENV !== 'undefined' && HOLOLAKE_ENV === 'production')
|
||||
? 'https://guanghulab.com/api' : '';
|
||||
|
||||
/**
|
||||
* ExecutionGuard 构造函数
|
||||
* @param {Object} chatInterface - 聊天界面接口对象
|
||||
*/
|
||||
function ExecutionGuard(chatInterface) {
|
||||
this.chat = chatInterface || {};
|
||||
this.locked = false;
|
||||
this.executionId = null;
|
||||
this._pollTimer = null;
|
||||
this._logIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入执行保护状态
|
||||
*/
|
||||
ExecutionGuard.prototype.lockExecution = function(executionId) {
|
||||
this.locked = true;
|
||||
this.executionId = executionId;
|
||||
this._logIndex = 0;
|
||||
|
||||
// 1. 禁用输入框
|
||||
var input = this.chat.inputField || document.getElementById('chat-input');
|
||||
var sendBtn = this.chat.sendButton || document.getElementById('send-btn');
|
||||
|
||||
if (input) {
|
||||
input.disabled = true;
|
||||
input.placeholder = '⏳ 系统执行中…请等待完成';
|
||||
input.classList.add('execution-locked');
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = true;
|
||||
}
|
||||
|
||||
// 2. 显示执行状态条
|
||||
this._showStatusBar('🔄', '指令已接收,系统正在执行…', 'blue');
|
||||
|
||||
// 3. 开始轮询执行状态
|
||||
this._pollExecutionStatus(executionId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 轮询执行状态
|
||||
*/
|
||||
ExecutionGuard.prototype._pollExecutionStatus = function(executionId) {
|
||||
var self = this;
|
||||
|
||||
var poll = function() {
|
||||
if (!self.locked) return;
|
||||
if (!API_BASE) {
|
||||
// 无后端时模拟完成
|
||||
setTimeout(function() { self.unlockExecution({ reply: '(本地模式:执行模拟完成)' }); }, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
var ctrl = new AbortController();
|
||||
var tid = setTimeout(function() { ctrl.abort(); }, 10000);
|
||||
|
||||
fetch(API_BASE + '/execution/' + executionId + '/status?after=' + self._logIndex, {
|
||||
signal: ctrl.signal
|
||||
})
|
||||
.then(function(res) { clearTimeout(tid); return res.json(); })
|
||||
.then(function(status) {
|
||||
// 更新进度
|
||||
self._updateProgressBar(status.progress || 0);
|
||||
|
||||
// 展示新日志
|
||||
if (status.newLogs && status.newLogs.length > 0) {
|
||||
for (var i = 0; i < status.newLogs.length; i++) {
|
||||
self._appendLogEntry(status.newLogs[i]);
|
||||
}
|
||||
self._logIndex += status.newLogs.length;
|
||||
}
|
||||
|
||||
if (status.state === 'completed') {
|
||||
self.unlockExecution(status.result || {});
|
||||
} else if (status.state === 'failed' || status.state === 'timeout') {
|
||||
self.handleExecutionFailure(status.error || { message: '执行失败' });
|
||||
} else {
|
||||
self._pollTimer = setTimeout(poll, 2000);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
clearTimeout(tid);
|
||||
// 网络错误不中断执行,继续轮询
|
||||
self._pollTimer = setTimeout(poll, 5000);
|
||||
});
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行完成,解锁界面
|
||||
*/
|
||||
ExecutionGuard.prototype.unlockExecution = function(result) {
|
||||
this.locked = false;
|
||||
this.executionId = null;
|
||||
if (this._pollTimer) { clearTimeout(this._pollTimer); this._pollTimer = null; }
|
||||
|
||||
// 恢复输入框
|
||||
var input = this.chat.inputField || document.getElementById('chat-input');
|
||||
var sendBtn = this.chat.sendButton || document.getElementById('send-btn');
|
||||
|
||||
if (input) {
|
||||
input.disabled = false;
|
||||
input.placeholder = '和铸渊说话…';
|
||||
input.classList.remove('execution-locked');
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
|
||||
// 更新状态条
|
||||
this._showStatusBar('✅', '执行完成', 'green');
|
||||
var self = this;
|
||||
setTimeout(function() { self._hideStatusBar(); }, 5000);
|
||||
|
||||
// 展示结果
|
||||
if (result && result.reply && this.chat.appendMessage) {
|
||||
this.chat.appendMessage({ role: 'assistant', content: result.reply });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行失败处理
|
||||
*/
|
||||
ExecutionGuard.prototype.handleExecutionFailure = function(error) {
|
||||
this.locked = false;
|
||||
this.executionId = null;
|
||||
if (this._pollTimer) { clearTimeout(this._pollTimer); this._pollTimer = null; }
|
||||
|
||||
var input = this.chat.inputField || document.getElementById('chat-input');
|
||||
var sendBtn = this.chat.sendButton || document.getElementById('send-btn');
|
||||
|
||||
if (input) {
|
||||
input.disabled = false;
|
||||
input.placeholder = '和铸渊说话…';
|
||||
input.classList.remove('execution-locked');
|
||||
}
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
|
||||
this._showStatusBar('❌', '执行失败 · 系统已自动回滚到执行前状态', 'red');
|
||||
var self = this;
|
||||
setTimeout(function() { self._hideStatusBar(); }, 10000);
|
||||
|
||||
var msg = error && error.message ? error.message : '未知错误';
|
||||
if (this.chat.appendMessage) {
|
||||
this.chat.appendMessage({
|
||||
role: 'assistant',
|
||||
content: '❌ 操作执行失败:' + msg +
|
||||
'\n\n系统已自动回滚到执行前的状态。没有任何数据被修改。\n' +
|
||||
'你可以重新描述你想做的事,或者说「发生了什么」来查看详细日志。'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 拦截浏览器关闭/刷新
|
||||
*/
|
||||
ExecutionGuard.prototype.setupBrowserGuard = function() {
|
||||
var self = this;
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (self.locked) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '⚠️ 系统正在执行操作,关闭页面不会中断执行,但你将无法看到执行结果。';
|
||||
return e.returnValue;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ====== 内部 UI 辅助方法 ======
|
||||
|
||||
ExecutionGuard.prototype._showStatusBar = function(icon, text, color) {
|
||||
var bar = document.getElementById('execution-status-bar');
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'execution-status-bar';
|
||||
bar.className = 'execution-status-bar';
|
||||
var chatArea = document.querySelector('.chat-messages') || document.querySelector('.chat-container') || document.body;
|
||||
chatArea.appendChild(bar);
|
||||
}
|
||||
bar.className = 'execution-status-bar ' + (color || 'blue');
|
||||
bar.innerHTML = '<span class="exec-icon">' + icon + '</span> <span class="exec-text">' + text + '</span>' +
|
||||
'<div class="execution-progress"><div class="execution-progress-fill" id="exec-progress-fill" style="width:5%"></div></div>';
|
||||
bar.style.display = 'flex';
|
||||
// 注意:没有取消按钮。这不是遗漏,是设计。
|
||||
};
|
||||
|
||||
ExecutionGuard.prototype._hideStatusBar = function() {
|
||||
var bar = document.getElementById('execution-status-bar');
|
||||
if (bar) bar.style.display = 'none';
|
||||
};
|
||||
|
||||
ExecutionGuard.prototype._updateProgressBar = function(percent) {
|
||||
var fill = document.getElementById('exec-progress-fill');
|
||||
if (fill) fill.style.width = Math.max(5, Math.min(100, percent)) + '%';
|
||||
};
|
||||
|
||||
ExecutionGuard.prototype._appendLogEntry = function(log) {
|
||||
var container = document.getElementById('execution-log-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'execution-log-container';
|
||||
container.className = 'execution-log-container';
|
||||
var bar = document.getElementById('execution-status-bar');
|
||||
if (bar && bar.parentNode) {
|
||||
bar.parentNode.insertBefore(container, bar.nextSibling);
|
||||
} else {
|
||||
(document.querySelector('.chat-messages') || document.body).appendChild(container);
|
||||
}
|
||||
}
|
||||
var entry = document.createElement('div');
|
||||
entry.className = 'execution-log-entry';
|
||||
var icon = log.success ? '✅' : '⏳';
|
||||
var time = log.timestamp ? new Date(log.timestamp).toLocaleTimeString('zh-CN') : '';
|
||||
entry.innerHTML = '<span class="log-icon">' + icon + '</span>' +
|
||||
'<span class="log-msg">' + (log.message || '') + '</span>' +
|
||||
'<span class="log-time">' + time + '</span>';
|
||||
container.appendChild(entry);
|
||||
};
|
||||
|
||||
// 导出到全局
|
||||
window.ExecutionGuard = ExecutionGuard;
|
||||
|
||||
})(window);
|
||||
Loading…
Reference in New Issue