1040 lines
39 KiB
JavaScript
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,
|
|
};
|