'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, };