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

1040 lines
39 KiB
JavaScript

'use strict';
const MAX_VERSION = 2_147_483_647;
const MAX_STATUS_PROMPT_LENGTH = 4000;
const STATUS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/;
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
const COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
const ACTOR_SOURCE_ALIASES = Object.freeze({
user: 'user',
human: 'user',
mcp: 'mcp',
agent: 'mcp',
classifier: 'classifier',
classification: 'classifier',
hook: 'hook',
lifecycle: 'hook',
default: 'default',
system: 'default',
});
const TRACKING_SOURCE_SET = new Set(['default', 'hook', 'mcp', 'classifier', 'user']);
const SYSTEM_STATUS_DEFINITIONS = Object.freeze([
Object.freeze({
id: 'unassigned',
label: '待认领',
description: '已加入任务看板,但尚未开始处理。',
prompt: '当任务尚未开始、尚未分配,或缺少足够信息判断处理阶段时选择此列。',
color: '#64748b',
order: 10,
system: true,
enabled: true,
}),
Object.freeze({
id: 'in_progress',
label: '处理中',
description: '任务正在推进。',
prompt: '当任务正在实现、排查、验证或持续推进,且没有等待用户或遇到阻碍时选择此列。',
color: '#06b6d4',
order: 20,
system: true,
enabled: true,
}),
Object.freeze({
id: 'waiting_user',
label: '等待确认',
description: '等待用户输入、验收或外部条件。',
prompt: '当继续推进必须等待用户补充信息、确认方案、授权或验收时选择此列。',
color: '#f59e0b',
order: 30,
system: true,
enabled: true,
}),
Object.freeze({
id: 'blocked',
label: '遇到阻碍',
description: '发生错误或当前无法继续。',
prompt: '当任务因错误、依赖缺失、权限或外部条件而无法继续时选择此列。',
color: '#ef4444',
order: 40,
system: true,
enabled: true,
}),
Object.freeze({
id: 'completed',
label: '已完成',
description: '任务业务目标已经完成。',
prompt: '仅当用户目标已经实际完成并完成必要验证时选择此列;单轮对话结束不代表完成。',
color: '#22c55e',
order: 50,
system: true,
enabled: true,
}),
]);
const SYSTEM_STATUS_IDS = Object.freeze(SYSTEM_STATUS_DEFINITIONS.map((item) => item.id));
const SYSTEM_STATUS_ID_SET = new Set(SYSTEM_STATUS_IDS);
const LIFECYCLE_EVENT_TYPES = new Set([
'turn_started',
'user_input_requested',
'turn_completed',
'turn_failed',
'user_message_received',
]);
const ERROR_MESSAGES = Object.freeze({
task_tracking_disabled: '当前会话未开启任务跟踪。',
task_tracking_forbidden: 'Agent 和生命周期 Hook 无权启用或关闭任务跟踪。',
task_archive_forbidden: '只有用户操作可以归档或取消归档任务。',
task_status_forbidden: '只有用户操作可以管理状态定义。',
task_status_unknown: '指定的任务状态不存在。',
task_status_invalid: '任务状态输入无效。',
task_status_in_use: '该自定义状态仍被任务引用,必须指定迁移目标。',
task_version_conflict: '任务版本已变化,请刷新后重试。',
task_session_not_found: '找不到指定会话。',
task_persistence_failed: '任务状态保存失败。',
});
class TaskBoardError extends Error {
constructor(code, message, details) {
super(message || ERROR_MESSAGES[code] || '任务看板操作失败。');
this.name = 'TaskBoardError';
this.code = code;
if (details !== undefined) this.details = details;
}
}
function fail(code, message, details) {
throw new TaskBoardError(code, message, details);
}
function isPlainObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function cloneJson(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function hasOwn(value, key) {
return Object.prototype.hasOwnProperty.call(value, key);
}
function assertPlainObject(value, label = 'input') {
if (!isPlainObject(value)) {
fail('task_status_invalid', `${label} 必须是对象。`);
}
}
function assertAllowedKeys(value, allowedKeys, label = 'input') {
const unexpected = Object.keys(value).filter((key) => !allowedKeys.has(key));
if (unexpected.length > 0) {
fail('task_status_invalid', `${label} 包含不支持的字段。`, { fields: unexpected });
}
}
function codePointLength(value) {
return Array.from(value).length;
}
function validateString(value, options = {}) {
const {
field = 'value',
maxLength = 1000,
minLength = 0,
trim = false,
pattern = null,
} = options;
if (typeof value !== 'string') {
fail('task_status_invalid', `${field} 必须是字符串。`);
}
const normalized = trim ? value.trim() : value;
const length = codePointLength(normalized);
if (length < minLength || length > maxLength || normalized.includes('\0')) {
fail('task_status_invalid', `${field} 长度无效。`, { minLength, maxLength });
}
if (pattern && !pattern.test(normalized)) {
fail('task_status_invalid', `${field} 格式无效。`);
}
return normalized;
}
function validateStatusId(value, field = 'statusId') {
return validateString(value, {
field,
minLength: 1,
maxLength: 80,
trim: true,
pattern: STATUS_ID_PATTERN,
});
}
function validateStatusPrompt(value) {
return validateString(value, {
field: 'prompt',
minLength: 1,
maxLength: MAX_STATUS_PROMPT_LENGTH,
trim: true,
});
}
function legacyStatusPrompt(label, description) {
const subject = description || label;
return `当任务符合“${subject}”时选择此列。`;
}
function validateSessionId(value) {
if (typeof value !== 'string' || value !== value.trim() || !SESSION_ID_PATTERN.test(value)) {
fail('task_status_invalid', 'sessionId 格式无效。');
}
return value;
}
function validateColor(value) {
const color = validateString(value, {
field: 'color',
minLength: 7,
maxLength: 7,
trim: true,
pattern: COLOR_PATTERN,
});
return color.toLowerCase();
}
function validateOrder(value) {
if (!Number.isSafeInteger(value) || value < -100_000 || value > 100_000) {
fail('task_status_invalid', 'order 必须是 -100000 到 100000 之间的整数。');
}
return value;
}
function validateVersion(value, field = 'version') {
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_VERSION) {
fail('task_status_invalid', `${field} 必须是 0 到 ${MAX_VERSION} 之间的整数。`);
}
return value;
}
function validateBoolean(value, field) {
if (typeof value !== 'boolean') {
fail('task_status_invalid', `${field} 必须是布尔值。`);
}
return value;
}
function normalizeIsoTimestamp(value, field = 'occurredAt') {
if (typeof value !== 'string' || codePointLength(value) > 64) {
fail('task_status_invalid', `${field} 必须是有效的 ISO-8601 字符串。`);
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
fail('task_status_invalid', `${field} 必须是有效的 ISO-8601 字符串。`);
}
return parsed.toISOString();
}
function storedIsoOrNull(value) {
if (typeof value !== 'string' || value.length > 64) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
function nextVersion(currentVersion) {
if (!Number.isSafeInteger(currentVersion) || currentVersion < 0 || currentVersion >= MAX_VERSION) {
fail('task_version_conflict', '任务版本已达到上限。', { currentVersion });
}
return currentVersion + 1;
}
function readExpectedVersion(input, actor) {
const candidates = [];
if (isPlainObject(input)) {
if (hasOwn(input, 'version')) candidates.push(['version', input.version]);
if (hasOwn(input, 'expectedVersion')) candidates.push(['expectedVersion', input.expectedVersion]);
}
if (isPlainObject(actor)) {
if (hasOwn(actor, 'version')) candidates.push(['actor.version', actor.version]);
if (hasOwn(actor, 'expectedVersion')) candidates.push(['actor.expectedVersion', actor.expectedVersion]);
}
if (candidates.length === 0) return null;
const validated = candidates.map(([field, value]) => validateVersion(value, field));
if (validated.some((value) => value !== validated[0])) {
fail('task_status_invalid', 'version 与 expectedVersion 不一致。');
}
return validated[0];
}
function assertVersion(expectedVersion, currentVersion, scope = 'task') {
if (expectedVersion !== null && expectedVersion !== currentVersion) {
fail('task_version_conflict', undefined, {
scope,
expectedVersion,
currentVersion,
});
}
}
function normalizeActor(actor, fallbackSource = 'user') {
let rawSource = fallbackSource;
let rawId = '';
if (typeof actor === 'string') {
rawSource = actor;
rawId = actor;
} else if (actor !== undefined && actor !== null) {
assertPlainObject(actor, 'actor');
rawSource = actor.source || actor.type || actor.kind || fallbackSource;
rawId = actor.id || actor.actorId || actor.userId || '';
}
if (typeof rawSource !== 'string') {
fail('task_status_invalid', 'actor.source 无效。');
}
const source = ACTOR_SOURCE_ALIASES[rawSource.trim().toLowerCase()];
if (!source) fail('task_status_invalid', 'actor.source 无效。');
const id = rawId
? validateString(rawId, { field: 'actor.id', minLength: 1, maxLength: 120, trim: true })
: source;
return { source, id };
}
function compareDefinitions(left, right) {
return left.order - right.order
|| Number(right.system) - Number(left.system)
|| left.label.localeCompare(right.label, 'zh-CN')
|| left.id.localeCompare(right.id);
}
function defaultStatusMap() {
return new Map(SYSTEM_STATUS_DEFINITIONS.map((definition) => [definition.id, { ...definition }]));
}
function normalizeStoredStatusDefinition(raw, seenIds) {
assertPlainObject(raw, 'statusDefinition');
const id = validateStatusId(raw.id, 'statusDefinition.id');
if (seenIds.has(id)) {
fail('task_status_invalid', '状态定义 ID 重复。', { statusId: id });
}
seenIds.add(id);
if (SYSTEM_STATUS_ID_SET.has(id)) {
const original = SYSTEM_STATUS_DEFINITIONS.find((item) => item.id === id);
if (hasOwn(raw, 'system') && raw.system !== true) {
fail('task_status_invalid', '系统状态的 system 必须保持为 true。', { statusId: id });
}
if (hasOwn(raw, 'enabled') && raw.enabled !== true) {
fail('task_status_invalid', '系统状态不可停用。', { statusId: id });
}
return {
...original,
label: hasOwn(raw, 'label')
? validateString(raw.label, { field: 'label', minLength: 1, maxLength: 120, trim: true })
: original.label,
prompt: hasOwn(raw, 'prompt') ? validateStatusPrompt(raw.prompt) : original.prompt,
color: hasOwn(raw, 'color') ? validateColor(raw.color) : original.color,
order: hasOwn(raw, 'order') ? validateOrder(raw.order) : original.order,
};
}
if (hasOwn(raw, 'system') && raw.system !== false) {
fail('task_status_invalid', '自定义状态的 system 必须为 false。', { statusId: id });
}
if (typeof raw.enabled !== 'boolean') {
fail('task_status_invalid', '自定义状态必须包含布尔类型 enabled。', { statusId: id });
}
const label = validateString(raw.label, {
field: 'label', minLength: 1, maxLength: 120, trim: true,
});
const description = hasOwn(raw, 'description')
? validateString(raw.description, { field: 'description', maxLength: 1000 })
: '';
return {
id,
label,
description,
prompt: hasOwn(raw, 'prompt')
? validateStatusPrompt(raw.prompt)
: legacyStatusPrompt(label, description),
color: validateColor(raw.color),
order: validateOrder(raw.order),
system: false,
enabled: raw.enabled,
};
}
function normalizeStatusConfig(rawConfig) {
if (rawConfig === undefined || rawConfig === null) {
return { version: 0, definitions: defaultStatusMap() };
}
const config = Array.isArray(rawConfig)
? { version: 0, definitions: rawConfig }
: rawConfig;
assertPlainObject(config, 'statusConfig');
const version = hasOwn(config, 'version') ? validateVersion(config.version, 'statusConfig.version') : 0;
const rawDefinitions = config.definitions || config.statuses || config.items || [];
if (!Array.isArray(rawDefinitions) || rawDefinitions.length > 105) {
fail('task_status_invalid', '状态定义列表无效或数量超过上限。');
}
const definitions = defaultStatusMap();
const seenIds = new Set();
for (const raw of rawDefinitions) {
const normalized = normalizeStoredStatusDefinition(raw, seenIds);
definitions.set(normalized.id, normalized);
}
return { version, definitions };
}
function callSyncDependency(fn, args, label) {
const result = fn(...args);
if (result && typeof result.then === 'function') {
throw new TypeError(`${label} 必须是同步依赖。`);
}
return result;
}
function createTaskBoardService(deps = {}) {
if (!isPlainObject(deps)) throw new TypeError('deps 必须是对象。');
const sessionMap = deps.sessions instanceof Map ? deps.sessions : null;
const loadSession = deps.loadSession || deps.getSession || ((sessionId) => sessionMap?.get(sessionId) || null);
const saveSession = deps.saveSession || deps.persistSession || deps.updateSession || ((session) => {
if (!sessionMap) return false;
sessionMap.set(session.id, cloneJson(session));
return true;
});
const listSessions = deps.listSessions || deps.getSessions || (() => sessionMap ? Array.from(sessionMap.values()) : []);
const loadStatusDefinitions = deps.loadStatusDefinitions
|| deps.loadStatusConfig
|| deps.readStatusDefinitions
|| (() => deps.statusConfig || deps.statusDefinitions || null);
const saveStatusDefinitions = deps.saveStatusDefinitions
|| deps.saveStatusConfig
|| deps.writeStatusDefinitions
|| null;
const nowProvider = typeof deps.now === 'function' ? deps.now : () => new Date();
for (const [label, dependency] of [
['loadSession', loadSession],
['saveSession', saveSession],
['listSessions', listSessions],
['loadStatusDefinitions', loadStatusDefinitions],
]) {
if (typeof dependency !== 'function') throw new TypeError(`${label} 必须是函数。`);
}
if (saveStatusDefinitions !== null && typeof saveStatusDefinitions !== 'function') {
throw new TypeError('saveStatusDefinitions 必须是函数。');
}
const loadedStatusConfig = callSyncDependency(loadStatusDefinitions, [], 'loadStatusDefinitions');
let { version: statusConfigVersion, definitions: statusMap } = normalizeStatusConfig(loadedStatusConfig);
function timestamp(override) {
if (override !== undefined && override !== null) return normalizeIsoTimestamp(override);
const value = callSyncDependency(nowProvider, [], 'now');
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) fail('task_status_invalid', 'now 返回了无效时间。');
return value.toISOString();
}
if (typeof value === 'number' && Number.isFinite(value)) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) fail('task_status_invalid', 'now 返回了无效时间。');
return parsed.toISOString();
}
return normalizeIsoTimestamp(value, 'now');
}
function sortedDefinitions(map = statusMap) {
return Array.from(map.values()).map((item) => ({ ...item })).sort(compareDefinitions);
}
function getStatusDefinitions(options = {}) {
if (options !== undefined && !isPlainObject(options)) {
fail('task_status_invalid', 'options 必须是对象。');
}
assertAllowedKeys(options, new Set(['enabledOnly']), 'options');
if (hasOwn(options, 'enabledOnly')) validateBoolean(options.enabledOnly, 'enabledOnly');
const enabledOnly = options.enabledOnly === true;
const result = sortedDefinitions().filter((item) => !enabledOnly || item.enabled);
Object.defineProperty(result, 'version', {
configurable: false,
enumerable: false,
writable: false,
value: statusConfigVersion,
});
return result;
}
function getStatusDefinitionSnapshot(options = {}) {
return {
version: statusConfigVersion,
definitions: getStatusDefinitions(options),
};
}
function persistStatusConfig(nextMap, nextConfigVersion) {
const payload = {
version: nextConfigVersion,
definitions: sortedDefinitions(nextMap),
};
if (saveStatusDefinitions) {
const saved = callSyncDependency(saveStatusDefinitions, [cloneJson(payload)], 'saveStatusDefinitions');
if (saved === false) fail('task_persistence_failed', '状态定义保存失败。');
}
statusMap = nextMap;
statusConfigVersion = nextConfigVersion;
}
function loadSessionForTask(sessionId) {
const id = validateSessionId(sessionId);
const loaded = callSyncDependency(loadSession, [id], 'loadSession');
if (!loaded || typeof loaded !== 'object') {
fail('task_session_not_found', undefined, { sessionId: id });
}
const session = cloneJson(loaded);
if (!session.id) session.id = id;
if (session.id !== id) {
fail('task_session_not_found', '会话 ID 与请求不一致。', { sessionId: id });
}
return session;
}
function persistSession(session) {
const saved = callSyncDependency(saveSession, [cloneJson(session)], 'saveSession');
if (saved === false) {
fail('task_persistence_failed', undefined, { sessionId: session.id });
}
}
function normalizeTracking(rawTracking) {
const raw = isPlainObject(rawTracking) ? rawTracking : {};
const rawStatusId = typeof raw.statusId === 'string' && STATUS_ID_PATTERN.test(raw.statusId)
? raw.statusId
: 'unassigned';
const definition = statusMap.get(rawStatusId) || statusMap.get('unassigned');
const source = ACTOR_SOURCE_ALIASES[String(raw.source || 'default').toLowerCase()] || 'default';
return {
enabled: raw.enabled === true,
statusId: definition.id,
source,
reason: typeof raw.reason === 'string' && codePointLength(raw.reason) <= 2000 ? raw.reason : '',
summary: typeof raw.summary === 'string' && codePointLength(raw.summary) <= 4000 ? raw.summary : '',
version: Number.isSafeInteger(raw.version) && raw.version >= 0 && raw.version <= MAX_VERSION
? raw.version
: 0,
enabledAt: storedIsoOrNull(raw.enabledAt),
disabledAt: storedIsoOrNull(raw.disabledAt),
statusUpdatedAt: storedIsoOrNull(raw.statusUpdatedAt),
archivedAt: storedIsoOrNull(raw.archivedAt),
archivedBy: typeof raw.archivedBy === 'string' && codePointLength(raw.archivedBy) <= 120
? raw.archivedBy
: null,
};
}
function hasCanonicalTracking(rawTracking) {
if (!isPlainObject(rawTracking)) return false;
const definition = typeof rawTracking.statusId === 'string'
? statusMap.get(rawTracking.statusId)
: null;
if (!definition) return false;
const requiredFields = [
'enabled', 'statusId', 'source', 'reason', 'summary',
'version', 'enabledAt', 'disabledAt', 'statusUpdatedAt', 'archivedAt', 'archivedBy',
];
return requiredFields.every((field) => hasOwn(rawTracking, field))
&& Object.keys(rawTracking).every((field) => requiredFields.includes(field))
&& typeof rawTracking.enabled === 'boolean'
&& TRACKING_SOURCE_SET.has(rawTracking.source)
&& Number.isSafeInteger(rawTracking.version)
&& rawTracking.version >= 0
&& rawTracking.version <= MAX_VERSION;
}
function taskSnapshot(session) {
const taskTracking = normalizeTracking(session.taskTracking);
const definition = statusMap.get(taskTracking.statusId) || statusMap.get('unassigned');
return {
id: session.id,
sessionId: session.id,
title: typeof session.title === 'string' ? session.title : '',
agent: typeof session.agent === 'string' ? session.agent : '',
cwd: typeof session.cwd === 'string' ? session.cwd : '',
projectName: typeof session.projectName === 'string' ? session.projectName : '',
created: storedIsoOrNull(session.created),
updated: storedIsoOrNull(session.updated),
archived: Boolean(taskTracking.archivedAt),
status: { ...definition },
taskTracking,
};
}
function getTask(sessionId) {
return taskSnapshot(loadSessionForTask(sessionId));
}
function listSessionRecords() {
const records = callSyncDependency(listSessions, [], 'listSessions');
if (!Array.isArray(records)) {
fail('task_status_invalid', 'listSessions 必须返回数组。');
}
return records.map((record) => {
if (typeof record === 'string') return loadSessionForTask(record);
if (!record || typeof record !== 'object') return null;
if (record.taskTracking || !record.id) return cloneJson(record);
try {
return loadSessionForTask(record.id);
} catch (error) {
if (error instanceof TaskBoardError && error.code === 'task_session_not_found') return cloneJson(record);
throw error;
}
}).filter(Boolean);
}
function normalizeFilterValues(value, field, validator) {
if (value === undefined || value === null) return null;
if (typeof value === 'string' && value.trim() === '') return null;
const values = Array.isArray(value) ? value : [value];
if (values.length === 0 || values.length > 50) {
fail('task_status_invalid', `${field} 过滤数量无效。`);
}
return new Set(values.map((item) => validator(item, field)));
}
function listTasks(filters = {}) {
assertPlainObject(filters, 'filters');
assertAllowedKeys(filters, new Set([
'sessionId', 'includeDisabled', 'archived', 'includeArchived', 'statusId', 'statusIds',
'search', 'query',
]), 'filters');
if (hasOwn(filters, 'includeDisabled')) validateBoolean(filters.includeDisabled, 'includeDisabled');
if (hasOwn(filters, 'includeArchived')) validateBoolean(filters.includeArchived, 'includeArchived');
const sessionId = hasOwn(filters, 'sessionId') ? validateSessionId(filters.sessionId) : null;
const statusIds = normalizeFilterValues(filters.statusIds ?? filters.statusId, 'statusId', validateStatusId);
const searchValue = hasOwn(filters, 'search') ? filters.search : filters.query;
const search = searchValue !== undefined
? validateString(searchValue, { field: 'search', maxLength: 240, trim: true }).toLocaleLowerCase('zh-CN')
: '';
let archivedFilter = filters.includeArchived === true ? 'all' : false;
if (hasOwn(filters, 'archived')) {
if (filters.archived !== true && filters.archived !== false && filters.archived !== 'all') {
fail('task_status_invalid', 'archived 必须是 true、false 或 all。');
}
archivedFilter = filters.archived;
}
return listSessionRecords()
.filter((session) => {
const tracking = normalizeTracking(session.taskTracking);
return tracking.enabled || (filters.includeDisabled === true && isPlainObject(session.taskTracking));
})
.map(taskSnapshot)
.filter((task) => !sessionId || task.sessionId === sessionId)
.filter((task) => archivedFilter === 'all' || task.archived === archivedFilter)
.filter((task) => !statusIds || statusIds.has(task.taskTracking.statusId))
.filter((task) => {
if (!search) return true;
const haystack = [
task.sessionId,
task.title,
task.cwd,
task.projectName,
task.taskTracking.reason,
task.taskTracking.summary,
].join('\n').toLocaleLowerCase('zh-CN');
return haystack.includes(search);
})
.sort((left, right) => {
const leftTime = left.taskTracking.statusUpdatedAt || left.updated || left.created || '';
const rightTime = right.taskTracking.statusUpdatedAt || right.updated || right.created || '';
return rightTime.localeCompare(leftTime) || left.sessionId.localeCompare(right.sessionId);
});
}
function setTracking(sessionId, enabled, actor = { source: 'user' }) {
validateBoolean(enabled, 'enabled');
const normalizedActor = normalizeActor(actor, 'user');
if (normalizedActor.source !== 'user') fail('task_tracking_forbidden');
const session = loadSessionForTask(sessionId);
const current = normalizeTracking(session.taskTracking);
const expectedVersion = readExpectedVersion(null, actor);
assertVersion(expectedVersion, current.version);
const needsInitialization = enabled && !hasCanonicalTracking(session.taskTracking);
if (current.enabled === enabled && !needsInitialization) return taskSnapshot(session);
const occurredAt = timestamp();
const next = {
...current,
enabled,
version: nextVersion(current.version),
};
if (enabled) {
next.enabledAt = current.enabledAt || occurredAt;
next.disabledAt = null;
if (!next.statusUpdatedAt) next.statusUpdatedAt = occurredAt;
} else {
next.disabledAt = occurredAt;
}
session.taskTracking = next;
persistSession(session);
return taskSnapshot(session);
}
function normalizeStatusUpdate(update) {
assertPlainObject(update, 'update');
assertAllowedKeys(update, new Set([
'statusId', 'reason', 'summary', 'version', 'expectedVersion', 'turnId',
]), 'update');
if (!hasOwn(update, 'statusId')) fail('task_status_invalid', 'statusId 为必填项。');
const normalized = {
statusId: validateStatusId(update.statusId),
hasReason: hasOwn(update, 'reason'),
hasSummary: hasOwn(update, 'summary'),
};
if (normalized.hasReason) {
normalized.reason = validateString(update.reason, { field: 'reason', maxLength: 2000 });
}
if (normalized.hasSummary) {
normalized.summary = validateString(update.summary, { field: 'summary', maxLength: 4000 });
}
if (hasOwn(update, 'turnId')) {
normalized.turnId = validateString(update.turnId, {
field: 'turnId', minLength: 1, maxLength: 128, trim: true,
});
}
return normalized;
}
function updateStatus(sessionId, update, actor = { source: 'user' }) {
const normalizedActor = normalizeActor(actor, 'user');
if (!['user', 'mcp', 'classifier'].includes(normalizedActor.source)) {
fail('task_status_forbidden', '只有用户、当前来源会话的 MCP 或内部分类器可以更新任务状态。');
}
const session = loadSessionForTask(sessionId);
const current = normalizeTracking(session.taskTracking);
if (!current.enabled) fail('task_tracking_disabled');
const normalizedUpdate = normalizeStatusUpdate(update);
const expectedVersion = readExpectedVersion(update, actor);
assertVersion(expectedVersion, current.version);
const definition = statusMap.get(normalizedUpdate.statusId);
if (!definition) fail('task_status_unknown', undefined, { statusId: normalizedUpdate.statusId });
if (!definition.enabled) {
fail('task_status_invalid', '目标状态已停用。', { statusId: normalizedUpdate.statusId });
}
const occurredAt = timestamp();
const next = {
...current,
statusId: definition.id,
source: normalizedActor.source,
statusUpdatedAt: occurredAt,
version: nextVersion(current.version),
};
if (normalizedUpdate.hasReason) next.reason = normalizedUpdate.reason;
if (normalizedUpdate.hasSummary) next.summary = normalizedUpdate.summary;
session.taskTracking = next;
persistSession(session);
return {
...taskSnapshot(session),
changed: true,
};
}
function normalizeDefinitionUpsert(input, current) {
assertPlainObject(input, 'statusDefinition');
assertAllowedKeys(input, new Set([
'id', 'label', 'description', 'prompt', 'color', 'order', 'system', 'enabled',
'version', 'expectedVersion',
]), 'statusDefinition');
const id = validateStatusId(input.id, 'statusDefinition.id');
const systemDefinition = SYSTEM_STATUS_ID_SET.has(id);
if (systemDefinition) {
const original = SYSTEM_STATUS_DEFINITIONS.find((item) => item.id === id);
for (const immutableField of ['description', 'system', 'enabled']) {
if (hasOwn(input, immutableField) && input[immutableField] !== original[immutableField]) {
fail('task_status_invalid', `系统状态的 ${immutableField} 不可修改。`, { statusId: id });
}
}
return {
...(current || original),
id,
description: original.description,
system: true,
enabled: true,
label: hasOwn(input, 'label')
? validateString(input.label, { field: 'label', minLength: 1, maxLength: 120, trim: true })
: (current || original).label,
prompt: hasOwn(input, 'prompt')
? validateStatusPrompt(input.prompt)
: (current || original).prompt,
color: hasOwn(input, 'color') ? validateColor(input.color) : (current || original).color,
order: hasOwn(input, 'order') ? validateOrder(input.order) : (current || original).order,
};
}
if (hasOwn(input, 'system') && input.system !== false) {
fail('task_status_invalid', '自定义状态的 system 必须为 false。');
}
if (!current) {
for (const requiredField of ['label', 'prompt', 'color', 'order', 'enabled']) {
if (!hasOwn(input, requiredField)) {
fail('task_status_invalid', `新建自定义状态缺少 ${requiredField}`);
}
}
}
const enabled = hasOwn(input, 'enabled')
? validateBoolean(input.enabled, 'enabled')
: current.enabled;
return {
id,
label: hasOwn(input, 'label')
? validateString(input.label, { field: 'label', minLength: 1, maxLength: 120, trim: true })
: current.label,
description: hasOwn(input, 'description')
? validateString(input.description, { field: 'description', maxLength: 1000 })
: (current ? current.description : ''),
prompt: hasOwn(input, 'prompt') ? validateStatusPrompt(input.prompt) : current.prompt,
color: hasOwn(input, 'color') ? validateColor(input.color) : current.color,
order: hasOwn(input, 'order') ? validateOrder(input.order) : current.order,
system: false,
enabled,
};
}
function upsertStatusDefinition(input, actor = { source: 'user' }) {
const normalizedActor = normalizeActor(actor, 'user');
if (normalizedActor.source !== 'user') fail('task_status_forbidden');
assertPlainObject(input, 'statusDefinition');
const id = validateStatusId(input.id, 'statusDefinition.id');
const current = statusMap.get(id) || null;
const expectedVersion = readExpectedVersion(input, actor);
assertVersion(expectedVersion, statusConfigVersion, 'statusDefinitions');
const definition = normalizeDefinitionUpsert(input, current);
const nextMap = new Map(statusMap);
nextMap.set(id, definition);
const nextConfigVersion = nextVersion(statusConfigVersion);
persistStatusConfig(nextMap, nextConfigVersion);
return {
version: statusConfigVersion,
definition: { ...definition },
migratedSessionIds: [],
definitions: getStatusDefinitions(),
};
}
function parseMigrationTarget(migrateTo) {
if (migrateTo === undefined || migrateTo === null || migrateTo === '') {
return { statusId: null, expectedVersion: null };
}
if (typeof migrateTo === 'string') {
return { statusId: validateStatusId(migrateTo, 'migrateTo'), expectedVersion: null };
}
assertPlainObject(migrateTo, 'migrateTo');
assertAllowedKeys(migrateTo, new Set(['statusId', 'version', 'expectedVersion']), 'migrateTo');
const statusId = validateStatusId(migrateTo.statusId, 'migrateTo.statusId');
return { statusId, expectedVersion: readExpectedVersion(migrateTo, null) };
}
function removeStatusDefinition(idInput, migrateTo, actor = { source: 'user' }) {
const normalizedActor = normalizeActor(actor, 'user');
if (normalizedActor.source !== 'user') fail('task_status_forbidden');
const id = validateStatusId(idInput, 'statusDefinition.id');
if (SYSTEM_STATUS_ID_SET.has(id)) {
fail('task_status_invalid', '系统状态不可删除。', { statusId: id });
}
if (!statusMap.has(id)) fail('task_status_unknown', undefined, { statusId: id });
const migration = parseMigrationTarget(migrateTo);
const actorExpectedVersion = readExpectedVersion(null, actor);
if (migration.expectedVersion !== null
&& actorExpectedVersion !== null
&& migration.expectedVersion !== actorExpectedVersion) {
fail('task_status_invalid', '迁移版本与 actor 版本不一致。');
}
assertVersion(migration.expectedVersion ?? actorExpectedVersion, statusConfigVersion, 'statusDefinitions');
let targetDefinition = null;
if (migration.statusId) {
if (migration.statusId === id) fail('task_status_invalid', '迁移目标不能是待删除状态。');
targetDefinition = statusMap.get(migration.statusId);
if (!targetDefinition) fail('task_status_unknown', undefined, { statusId: migration.statusId });
if (!targetDefinition.enabled) {
fail('task_status_invalid', '迁移目标状态已停用。', { statusId: migration.statusId });
}
}
const referencedSessions = listSessionRecords().filter((session) => (
normalizeTracking(session.taskTracking).statusId === id
));
if (referencedSessions.length > 0 && !targetDefinition) {
fail('task_status_in_use', undefined, {
statusId: id,
referenceCount: referencedSessions.length,
});
}
const occurredAt = timestamp();
const migrations = referencedSessions.map((session) => {
const original = cloneJson(session);
const current = normalizeTracking(session.taskTracking);
const next = {
...current,
statusId: targetDefinition.id,
source: normalizedActor.source,
statusUpdatedAt: occurredAt,
version: nextVersion(current.version),
};
session.taskTracking = next;
return { original, session };
});
const persisted = [];
try {
for (const migrationEntry of migrations) {
persistSession(migrationEntry.session);
persisted.push(migrationEntry);
}
const nextMap = new Map(statusMap);
nextMap.delete(id);
persistStatusConfig(nextMap, nextVersion(statusConfigVersion));
} catch (error) {
for (const migrationEntry of persisted.reverse()) {
try {
callSyncDependency(saveSession, [cloneJson(migrationEntry.original)], 'saveSession');
} catch {
// 回滚是尽力而为;保留原始错误,便于集成层记录。
}
}
throw error;
}
return {
version: statusConfigVersion,
removedId: id,
migrateTo: targetDefinition?.id || null,
migratedSessionIds: migrations.map((entry) => entry.session.id),
definitions: getStatusDefinitions(),
};
}
function setArchived(sessionId, archived, actor = { source: 'user' }) {
validateBoolean(archived, 'archived');
const normalizedActor = normalizeActor(actor, 'user');
if (normalizedActor.source !== 'user') fail('task_archive_forbidden');
const session = loadSessionForTask(sessionId);
const current = normalizeTracking(session.taskTracking);
if (!current.enabled) fail('task_tracking_disabled');
const expectedVersion = readExpectedVersion(null, actor);
assertVersion(expectedVersion, current.version);
if (Boolean(current.archivedAt) === archived) return taskSnapshot(session);
const next = {
...current,
archivedAt: archived ? timestamp() : null,
archivedBy: archived ? normalizedActor.id : null,
version: nextVersion(current.version),
};
session.taskTracking = next;
persistSession(session);
return taskSnapshot(session);
}
function normalizeLifecycleEvent(event) {
assertPlainObject(event, 'event');
assertAllowedKeys(event, new Set([
'type', 'turnId', 'occurredAt', 'outcome', 'pendingUserInput',
'error', 'version', 'expectedVersion',
]), 'event');
const type = validateString(event.type, { field: 'event.type', minLength: 1, maxLength: 64, trim: true });
if (!LIFECYCLE_EVENT_TYPES.has(type)) {
fail('task_status_invalid', '不支持的生命周期事件。', { type });
}
if (hasOwn(event, 'turnId')) {
validateString(event.turnId, { field: 'turnId', minLength: 1, maxLength: 128, trim: true });
}
if (hasOwn(event, 'outcome')) {
validateString(event.outcome, { field: 'outcome', maxLength: 64, trim: true });
}
if (hasOwn(event, 'pendingUserInput')) validateBoolean(event.pendingUserInput, 'pendingUserInput');
let errorMessage = '';
if (hasOwn(event, 'error') && event.error !== null) {
if (typeof event.error === 'string') {
errorMessage = validateString(event.error, { field: 'error', maxLength: 2000 });
} else if (isPlainObject(event.error) && typeof event.error.message === 'string') {
errorMessage = validateString(event.error.message, { field: 'error.message', maxLength: 2000 });
} else {
fail('task_status_invalid', 'error 必须是字符串、null 或包含 message 的对象。');
}
}
return {
...event,
type,
occurredAt: hasOwn(event, 'occurredAt') ? normalizeIsoTimestamp(event.occurredAt) : timestamp(),
errorMessage,
};
}
function lifecycleResult(session, options = {}) {
return {
changed: options.changed === true,
ignored: options.ignored === true,
reason: options.reason || null,
task: taskSnapshot(session),
};
}
function recordLifecycleEvent(sessionId, event) {
const normalizedEvent = normalizeLifecycleEvent(event);
const session = loadSessionForTask(sessionId);
const current = normalizeTracking(session.taskTracking);
if (!current.enabled) {
return lifecycleResult(session, { ignored: true, reason: 'task_tracking_disabled' });
}
const expectedVersion = readExpectedVersion(event, null);
assertVersion(expectedVersion, current.version);
if (current.archivedAt && normalizedEvent.type !== 'user_message_received') {
return lifecycleResult(session, { ignored: true, reason: 'task_archived' });
}
if (normalizedEvent.type !== 'user_message_received' || !current.archivedAt) {
return lifecycleResult(session, { ignored: true, reason: 'no_change' });
}
const next = {
...current,
archivedAt: null,
archivedBy: null,
};
next.version = nextVersion(current.version);
session.taskTracking = next;
persistSession(session);
return lifecycleResult(session, { changed: true });
}
return Object.freeze({
getStatusDefinitions,
getStatusDefinitionSnapshot,
upsertStatusDefinition,
removeStatusDefinition,
getTask,
listTasks,
setTracking,
updateStatus,
setArchived,
recordLifecycleEvent,
});
}
module.exports = {
MAX_VERSION,
STATUS_ID_PATTERN,
SYSTEM_STATUS_IDS,
SYSTEM_STATUS_DEFINITIONS,
TaskBoardError,
createTaskBoardService,
};