Files
cc-web/scripts/task-board-service-unit.js
2026-08-13 00:34:37 +08:00

489 lines
23 KiB
JavaScript
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.

'use strict';
const assert = require('assert');
const {
MAX_VERSION,
STATUS_ID_PATTERN,
SYSTEM_STATUS_IDS,
TaskBoardError,
createTaskBoardService,
} = require('../lib/task-board-service');
const {
TASK_BOARD_MCP_TOOL_DEFINITIONS,
createTaskBoardMcpToolDefinitions,
createTaskBoardMcpHandler,
createTaskBoardMcpHandlers,
} = require('../lib/task-board-mcp');
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function sessionFixture(id, overrides = {}) {
return {
id,
title: `任务 ${id}`,
agent: 'codexapp',
cwd: '/home/cc-web',
created: '2026-08-11T00:00:00.000Z',
updated: '2026-08-11T00:00:00.000Z',
messages: [],
...overrides,
};
}
function createHarness() {
const sessions = new Map([
['ordinary-a', sessionFixture('ordinary-a')],
['migration-b', sessionFixture('migration-b')],
['priority-c', sessionFixture('priority-c')],
['lifecycle-d', sessionFixture('lifecycle-d')],
['mcp-e', sessionFixture('mcp-e')],
['classifier-h', sessionFixture('classifier-h')],
]);
let statusConfig = null;
let clockTick = 0;
const service = createTaskBoardService({
loadSession(sessionId) {
const session = sessions.get(sessionId);
return session ? clone(session) : null;
},
saveSession(session) {
sessions.set(session.id, clone(session));
return true;
},
listSessions() {
return Array.from(sessions.values(), clone);
},
loadStatusDefinitions() {
return statusConfig;
},
saveStatusDefinitions(nextConfig) {
statusConfig = clone(nextConfig);
return true;
},
now() {
const value = new Date(Date.UTC(2026, 7, 11, 1, 0, clockTick));
clockTick += 1;
return value;
},
});
return {
service,
sessions,
getStatusConfig: () => clone(statusConfig),
};
}
function expectTaskError(fn, code) {
assert.throws(fn, (error) => (
error instanceof TaskBoardError && error.code === code
), `应抛出 ${code}`);
}
async function main() {
const harness = createHarness();
const { service } = harness;
// 普通会话默认关闭,读取不会把默认结构写回会话。
const ordinaryDefault = service.getTask('ordinary-a');
assert.strictEqual(ordinaryDefault.taskTracking.enabled, false);
assert.strictEqual(ordinaryDefault.taskTracking.statusId, 'unassigned');
assert.strictEqual('baseStatus' in ordinaryDefault.taskTracking, false);
assert.strictEqual('progress' in ordinaryDefault.taskTracking, false);
assert.strictEqual(ordinaryDefault.taskTracking.version, 0);
assert.strictEqual(harness.sessions.get('ordinary-a').taskTracking, undefined);
assert.deepStrictEqual(service.listTasks(), []);
assert.deepStrictEqual(service.listTasks({ includeDisabled: true, archived: 'all' }), []);
// 历史百分比字段只在读取时忽略,不返回,也不触发会话改写。
const legacyProgressSession = sessionFixture('legacy-progress-g', {
taskTracking: {
enabled: true,
statusId: 'in_progress',
source: 'mcp',
reason: '历史任务',
summary: '遗留百分比字段',
progress: 64,
reportingStatus: 'reported',
version: 3,
enabledAt: '2026-08-11T00:00:00.000Z',
disabledAt: null,
statusUpdatedAt: '2026-08-11T00:01:00.000Z',
completedAt: null,
archivedAt: null,
archivedBy: null,
},
});
harness.sessions.set(legacyProgressSession.id, clone(legacyProgressSession));
const legacyBeforeRead = clone(harness.sessions.get(legacyProgressSession.id));
const legacyTask = service.getTask(legacyProgressSession.id);
assert.strictEqual(legacyTask.taskTracking.statusId, 'in_progress');
assert.strictEqual('progress' in legacyTask.taskTracking, false);
assert.strictEqual('progress' in service.listTasks({ sessionId: legacyProgressSession.id })[0].taskTracking, false);
assert.deepStrictEqual(harness.sessions.get(legacyProgressSession.id), legacyBeforeRead);
harness.sessions.delete(legacyProgressSession.id);
expectTaskError(() => service.updateStatus('ordinary-a', {
statusId: 'in_progress',
}, { source: 'mcp' }), 'task_tracking_disabled');
expectTaskError(() => service.setTracking('ordinary-a', true, { source: 'mcp' }), 'task_tracking_forbidden');
const enabledA = service.setTracking('ordinary-a', true, {
source: 'user', id: 'user-a', expectedVersion: 0,
});
assert.strictEqual(enabledA.taskTracking.enabled, true);
assert.strictEqual(enabledA.taskTracking.source, 'default');
assert.strictEqual('progress' in enabledA.taskTracking, false);
assert.strictEqual(enabledA.taskTracking.version, 1);
assert.strictEqual(enabledA.agent, 'codexapp', 'Agent 仍应作为只读会话元数据返回');
assert.strictEqual(service.listTasks().length, 1);
assert.strictEqual(service.listTasks({ query: '', archived: false }).length, 1);
expectTaskError(() => service.listTasks({ agent: 'codexapp' }), 'task_status_invalid');
expectTaskError(() => service.listTasks({ baseStatus: 'in_progress' }), 'task_status_invalid');
expectTaskError(() => service.listTasks({ priority: 'high' }), 'task_status_invalid');
harness.sessions.set('preenabled-f', sessionFixture('preenabled-f', { taskTracking: { enabled: true } }));
const initializedF = service.setTracking('preenabled-f', true, { source: 'user', expectedVersion: 0 });
assert.strictEqual(initializedF.taskTracking.enabled, true);
assert.strictEqual(initializedF.taskTracking.version, 1);
assert(initializedF.taskTracking.enabledAt);
assert(initializedF.taskTracking.statusUpdatedAt);
// 每个定义都是独立列;系统列 ID 稳定,所有列都有可编辑分类提示词。
const defaults = service.getStatusDefinitions();
assert.deepStrictEqual(defaults.filter((item) => item.system).map((item) => item.id).sort(), [...SYSTEM_STATUS_IDS].sort());
assert(defaults.every((item) => typeof item.prompt === 'string' && item.prompt.length > 0));
assert(defaults.every((item) => !Object.prototype.hasOwnProperty.call(item, 'baseStatus')));
assert(STATUS_ID_PATTERN.test('waiting-release'));
assert(STATUS_ID_PATTERN.test('Waiting-Release_2'));
assert(STATUS_ID_PATTERN.test(`A${'b'.repeat(79)}`));
assert.strictEqual(STATUS_ID_PATTERN.test(`A${'b'.repeat(80)}`), false);
const updatedSystem = service.upsertStatusDefinition({
id: 'waiting_user',
label: '等待用户',
color: '#F59E0B',
order: 35,
prompt: '当后续推进必须等待用户补充、确认、授权或验收时选择此列。',
version: 0,
}, { source: 'user', id: 'admin' });
assert.strictEqual(updatedSystem.version, 1);
assert.strictEqual(updatedSystem.definition.prompt, '当后续推进必须等待用户补充、确认、授权或验收时选择此列。');
assert.strictEqual('baseStatus' in updatedSystem.definition, false);
assert.strictEqual(updatedSystem.definition.color, '#f59e0b');
expectTaskError(() => service.upsertStatusDefinition({
id: 'waiting_user', baseStatus: 'blocked', version: 1,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.removeStatusDefinition('completed', null, { source: 'user' }), 'task_status_invalid');
// 与 Firefox 真实提交完全一致definition 不包含 description。
const frontendDefinition = {
id: 'waiting-release',
label: '等待发布',
color: '#f97316',
order: 38,
enabled: true,
system: false,
prompt: '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。',
};
assert.strictEqual(Object.hasOwn(frontendDefinition, 'description'), false);
const customResult = service.upsertStatusDefinition({
...frontendDefinition,
expectedVersion: 1,
}, { source: 'user', id: 'admin' });
assert.strictEqual(customResult.version, 2);
assert.strictEqual(customResult.definition.system, false);
assert.strictEqual(customResult.definition.id, 'waiting-release');
assert.strictEqual(customResult.definition.prompt, '当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。');
assert.strictEqual('baseStatus' in customResult.definition, false);
const queriedCustomDefinition = service.getStatusDefinitions()
.find((item) => item.id === 'waiting-release');
assert(queriedCustomDefinition);
assert.strictEqual(queriedCustomDefinition.prompt, frontendDefinition.prompt);
assert.strictEqual('baseStatus' in queriedCustomDefinition, false);
assert.strictEqual(harness.getStatusConfig().version, 2);
assert.strictEqual(
harness.getStatusConfig().definitions.find((item) => item.id === 'waiting-release').prompt,
'当实现和验证已完成,但仍需等待部署环境或发布窗口时选择此列。',
);
assert(harness.getStatusConfig().definitions.every((item) => !('baseStatus' in item)));
expectTaskError(() => service.upsertStatusDefinition({
id: 'waiting-release', prompt: '', expectedVersion: 2,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.upsertStatusDefinition({
id: 'waiting-release', baseStatus: 'blocked', expectedVersion: 2,
}, { source: 'user' }), 'task_status_invalid');
const disabledCustom = service.upsertStatusDefinition({
id: 'paused_external',
label: '外部暂停',
description: '暂时停用的状态。',
prompt: '当任务明确因外部条件暂停时选择此列。',
color: '#a855f7',
order: 39,
enabled: false,
expectedVersion: 2,
}, { source: 'user', id: 'admin' });
assert.strictEqual(disabledCustom.version, 3);
expectTaskError(() => service.updateStatus('ordinary-a', {
statusId: 'paused_external', version: 1,
}, { source: 'mcp' }), 'task_status_invalid');
const customTaskA = service.updateStatus('ordinary-a', {
statusId: 'waiting-release',
reason: '代码已完成',
summary: '等待部署环境',
version: 1,
}, { source: 'mcp', id: 'agent-a' });
assert.strictEqual(customTaskA.taskTracking.statusId, 'waiting-release');
assert.strictEqual('baseStatus' in customTaskA.taskTracking, false);
assert.strictEqual('progress' in customTaskA.taskTracking, false);
assert.strictEqual('baseStatus' in customTaskA.status, false);
assert.strictEqual(customTaskA.taskTracking.source, 'mcp');
assert.strictEqual(customTaskA.taskTracking.version, 2);
const queriedCustomTasks = service.listTasks({ statusId: 'waiting-release' });
assert.deepStrictEqual(queriedCustomTasks.map((task) => task.sessionId), ['ordinary-a']);
assert.strictEqual('progress' in queriedCustomTasks[0].taskTracking, false);
// 自动分类器是独立审计来源,仍由 expectedVersion 负责并发门禁。
const classifierEnabled = service.setTracking('classifier-h', true, {
source: 'user', expectedVersion: 0,
});
const classifierUserMove = service.updateStatus('classifier-h', {
statusId: 'waiting_user', expectedVersion: classifierEnabled.taskTracking.version,
}, { source: 'user', id: 'user-h' });
const classifierMove = service.updateStatus('classifier-h', {
statusId: 'waiting-release',
reason: '用户已确认继续推进',
summary: '等待发布前的最终处理',
expectedVersion: classifierUserMove.taskTracking.version,
}, { source: 'classifier', id: 'task-status-classifier' });
assert.strictEqual(classifierMove.changed, true);
assert.strictEqual(classifierMove.taskTracking.statusId, 'waiting-release');
assert.strictEqual(classifierMove.taskTracking.source, 'classifier');
assert.strictEqual(classifierMove.taskTracking.version, classifierUserMove.taskTracking.version + 1);
expectTaskError(() => service.updateStatus('classifier-h', {
statusId: 'completed', expectedVersion: classifierUserMove.taskTracking.version,
}, { source: 'classifier' }), 'task_version_conflict');
// 停用跟踪仍保留状态;迁移会覆盖启用和停用会话中的引用。
const enabledB = service.setTracking('migration-b', true, { source: 'user', expectedVersion: 0 });
const customTaskB = service.updateStatus('migration-b', {
statusId: 'waiting-release', version: enabledB.taskTracking.version,
}, { source: 'mcp' });
const disabledB = service.setTracking('migration-b', false, {
source: 'user', expectedVersion: customTaskB.taskTracking.version,
});
assert.strictEqual(disabledB.taskTracking.enabled, false);
assert.strictEqual(disabledB.taskTracking.statusId, 'waiting-release');
assert.strictEqual(service.listTasks().some((task) => task.sessionId === 'migration-b'), false);
assert(service.listTasks({ includeDisabled: true, archived: 'all' })
.some((task) => task.sessionId === 'migration-b'));
expectTaskError(() => service.removeStatusDefinition(
'waiting-release', null, { source: 'user', expectedVersion: 3 },
), 'task_status_in_use');
expectTaskError(() => service.removeStatusDefinition(
'waiting-release', 'paused_external', { source: 'user', expectedVersion: 3 },
), 'task_status_invalid');
const migration = service.removeStatusDefinition(
'waiting-release', 'waiting_user', { source: 'user', expectedVersion: 3 },
);
assert.strictEqual(migration.version, 4);
assert.deepStrictEqual(migration.migratedSessionIds.sort(), ['classifier-h', 'migration-b', 'ordinary-a']);
assert.strictEqual(service.getTask('classifier-h').taskTracking.statusId, 'waiting_user');
assert.strictEqual(service.getTask('ordinary-a').taskTracking.statusId, 'waiting_user');
assert.strictEqual(service.getTask('migration-b').taskTracking.statusId, 'waiting_user');
assert.strictEqual(service.getTask('migration-b').taskTracking.enabled, false);
assert.strictEqual(service.getStatusDefinitions().some((item) => item.id === 'waiting-release'), false);
const reviewColumn = service.upsertStatusDefinition({
id: 'review_ready',
label: '等待评审',
description: '实现完成,等待代码或产品评审。',
prompt: '当实现与自测已完成,下一步明确是代码评审或产品评审时选择此列。',
color: '#38bdf8',
order: 36,
enabled: true,
version: 4,
}, { source: 'user', id: 'admin' });
assert.strictEqual(reviewColumn.version, 5);
assert.strictEqual('baseStatus' in reviewColumn.definition, false);
// 用户与来源 MCP 均按最后一次有效写入生效,版本检查独立处理并发冲突。
const enabledC = service.setTracking('priority-c', true, { source: 'user', expectedVersion: 0 });
const userStatus = service.updateStatus('priority-c', {
statusId: 'blocked',
reason: '用户手动标记',
version: enabledC.taskTracking.version,
}, { source: 'user', id: 'owner' });
const mcpStatus = service.updateStatus('priority-c', {
statusId: 'review_ready', version: userStatus.taskTracking.version,
}, { source: 'mcp', id: 'agent-c' });
assert.strictEqual(mcpStatus.changed, true);
assert.strictEqual(Object.hasOwn(mcpStatus, 'ignored'), false);
assert.strictEqual(Object.hasOwn(mcpStatus, 'ignoredReason'), false);
assert.strictEqual(mcpStatus.taskTracking.statusId, 'review_ready');
assert.strictEqual(mcpStatus.taskTracking.version, userStatus.taskTracking.version + 1);
assert.strictEqual(mcpStatus.taskTracking.source, 'mcp');
const ignoredHook = service.recordLifecycleEvent('priority-c', {
type: 'turn_started',
occurredAt: '2026-08-11T02:00:00.000Z',
version: mcpStatus.taskTracking.version,
});
assert.strictEqual(ignoredHook.ignored, true);
assert.strictEqual(ignoredHook.reason, 'no_change');
assert.strictEqual(ignoredHook.task.taskTracking.statusId, 'review_ready');
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'completed', version: 1,
}, { source: 'user' }), 'task_version_conflict');
// 严格校验状态 ID、颜色、退役字段、文本长度与版本。
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'Bad.Status', version: mcpStatus.taskTracking.version,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'blocked', progress: 42, version: mcpStatus.taskTracking.version,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'blocked', reason: 'x'.repeat(2001), version: mcpStatus.taskTracking.version,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.updateStatus('priority-c', {
statusId: 'blocked', version: MAX_VERSION + 1,
}, { source: 'user' }), 'task_status_invalid');
expectTaskError(() => service.upsertStatusDefinition({
id: 'bad_color',
label: '坏颜色',
description: '',
prompt: '当颜色校验测试命中时选择此列。',
color: 'red',
order: 1,
enabled: true,
version: 5,
}, { source: 'user' }), 'task_status_invalid');
// 归档是软标记Agent 无权归档;新用户消息只恢复归档,不推断任务列。
const enabledD = service.setTracking('lifecycle-d', true, { source: 'user', expectedVersion: 0 });
const completedD = service.updateStatus('lifecycle-d', {
statusId: 'completed',
summary: '已完成',
version: enabledD.taskTracking.version,
}, { source: 'mcp' });
expectTaskError(() => service.setArchived('lifecycle-d', true, {
source: 'mcp', expectedVersion: completedD.taskTracking.version,
}), 'task_archive_forbidden');
const archivedD = service.setArchived('lifecycle-d', true, {
source: 'user', id: 'owner', expectedVersion: completedD.taskTracking.version,
});
assert.strictEqual(archivedD.archived, true);
assert.strictEqual(archivedD.taskTracking.statusId, 'completed');
assert.strictEqual(service.listTasks().some((task) => task.sessionId === 'lifecycle-d'), false);
assert.strictEqual(service.listTasks({ archived: true }).length, 1);
const archivedTurn = service.recordLifecycleEvent('lifecycle-d', {
type: 'turn_started', version: archivedD.taskTracking.version,
});
assert.strictEqual(archivedTurn.reason, 'task_archived');
const reopenedD = service.recordLifecycleEvent('lifecycle-d', {
type: 'user_message_received',
occurredAt: '2026-08-11T03:00:00.000Z',
version: archivedD.taskTracking.version,
});
assert.strictEqual(reopenedD.changed, true);
assert.strictEqual(reopenedD.task.archived, false);
assert.strictEqual(reopenedD.task.taskTracking.statusId, 'completed');
assert.strictEqual('baseStatus' in reopenedD.task.taskTracking, false);
assert.strictEqual(Object.hasOwn(reopenedD.task.taskTracking, 'reportingStatus'), false);
const completedTurn = service.recordLifecycleEvent('lifecycle-d', {
type: 'turn_completed',
occurredAt: '2026-08-11T03:30:00.000Z',
version: reopenedD.task.taskTracking.version,
});
assert.strictEqual(completedTurn.changed, false);
assert.strictEqual(completedTurn.reason, 'no_change');
assert.strictEqual(completedTurn.task.taskTracking.statusId, 'completed');
assert.strictEqual(completedTurn.task.taskTracking.version, reopenedD.task.taskTracking.version);
assert.strictEqual(Object.hasOwn(completedTurn.task.taskTracking, 'reportingStatus'), false);
// MCP 工具按来源会话动态生成;静态工具表不再泄漏任何任务工具。
assert.deepStrictEqual(TASK_BOARD_MCP_TOOL_DEFINITIONS, []);
const mcpHandlers = createTaskBoardMcpHandlers(service);
assert.deepStrictEqual(
createTaskBoardMcpToolDefinitions(service, { sourceSessionId: 'mcp-e' }),
[],
);
const disabledUpdate = await mcpHandlers.ccweb_task_update({
statusId: 'in_progress',
}, { sourceSessionId: 'mcp-e' });
assert.strictEqual(disabledUpdate.ok, false);
assert.strictEqual(disabledUpdate.code, 'task_tracking_disabled');
const enabledE = service.setTracking('mcp-e', true, { source: 'user', expectedVersion: 0 });
const dynamicTools = createTaskBoardMcpToolDefinitions(service, { sourceSessionId: 'mcp-e' });
assert.deepStrictEqual(dynamicTools.map((tool) => tool.name), ['ccweb_task_update']);
const updateToolDefinition = dynamicTools[0];
const enabledDefinitions = service.getStatusDefinitions().filter((definition) => definition.enabled);
assert.deepStrictEqual(
updateToolDefinition.inputSchema.properties.statusId.enum,
enabledDefinitions.map((definition) => definition.id),
);
assert.deepStrictEqual(
Object.keys(updateToolDefinition.inputSchema.properties),
['statusId', 'reason', 'summary'],
);
assert.match(updateToolDefinition.description, /当前状态/);
assert.match(updateToolDefinition.description, /仅当当前对话任务状态真实发生变化时调用/);
assert.match(updateToolDefinition.description, /单轮开始、结束、运行停止不代表任务状态变化/);
for (const definition of enabledDefinitions) {
assert(updateToolDefinition.description.includes(definition.label));
assert(updateToolDefinition.description.includes(definition.id));
assert(updateToolDefinition.description.includes(definition.prompt));
}
assert.strictEqual(updateToolDefinition.description.includes('ccweb_task_status_list'), false);
const retiredMcpProgress = await mcpHandlers.ccweb_task_update({
statusId: 'review_ready',
progress: 10,
}, { sourceSessionId: 'mcp-e', actorId: 'agent-e' });
assert.strictEqual(retiredMcpProgress.ok, false);
assert.strictEqual(retiredMcpProgress.code, 'task_status_invalid');
assert.strictEqual(service.getTask('mcp-e').taskTracking.version, enabledE.taskTracking.version);
const mcpSuccess = await mcpHandlers.ccweb_task_update({
statusId: 'review_ready',
summary: 'MCP 根据列提示词判断为等待评审',
}, { sourceSessionId: 'mcp-e', actorId: 'agent-e' });
assert.strictEqual(mcpSuccess.ok, true);
assert.strictEqual(mcpSuccess.sessionId, 'mcp-e');
assert.strictEqual(mcpSuccess.currentStatus.statusId, 'review_ready');
assert.strictEqual(mcpSuccess.currentStatus.source, 'mcp');
assert.strictEqual(Object.hasOwn(mcpSuccess.currentStatus, 'progress'), false);
assert.strictEqual(Object.hasOwn(mcpSuccess.task.taskTracking, 'progress'), false);
assert.strictEqual(mcpSuccess.currentStatusDefinition.prompt, reviewColumn.definition.prompt);
assert.strictEqual(JSON.stringify(mcpSuccess).includes('baseStatus'), false);
const refreshedTools = createTaskBoardMcpToolDefinitions(service, { sourceSessionId: 'mcp-e' });
assert.match(refreshedTools[0].description, /等待评审[^\n]*review_ready|review_ready[^\n]*等待评审/);
const unknownStatus = await mcpHandlers.ccweb_task_update({
statusId: 'does_not_exist',
}, { sourceSessionId: 'mcp-e' });
assert.strictEqual(unknownStatus.code, 'task_status_unknown');
const missingContext = await mcpHandlers.ccweb_task_update({ statusId: 'in_progress' }, {});
assert.strictEqual(missingContext.code, 'task_session_not_found');
const spoofedContext = await mcpHandlers.ccweb_task_update({
statusId: 'in_progress', sourceSessionId: 'priority-c',
}, { sourceSessionId: 'mcp-e' });
assert.strictEqual(spoofedContext.code, 'task_status_invalid');
const dispatchMcp = createTaskBoardMcpHandler({ taskBoardService: service });
const retiredList = await dispatchMcp('ccweb_task_status_list', {}, { sessionId: 'mcp-e' });
assert.strictEqual(retiredList.code, 'unknown_tool');
const unknownTool = await dispatchMcp('ccweb_task_archive', {}, { sourceSessionId: 'mcp-e' });
assert.strictEqual(unknownTool.code, 'unknown_tool');
console.log('Task board service and MCP unit checks passed.');
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});