257 lines
9.9 KiB
JavaScript
257 lines
9.9 KiB
JavaScript
'use strict';
|
|
|
|
const LIFECYCLE_EVENT_TYPES = Object.freeze({
|
|
USER_MESSAGE_RECEIVED: 'user_message_received',
|
|
TURN_STARTED: 'turn_started',
|
|
USER_INPUT_REQUESTED: 'user_input_requested',
|
|
TURN_COMPLETED: 'turn_completed',
|
|
TURN_FAILED: 'turn_failed',
|
|
});
|
|
|
|
const SUPPORTED_EVENT_TYPES = new Set(Object.values(LIFECYCLE_EVENT_TYPES));
|
|
const EVENT_TYPE_ALIASES = Object.freeze({
|
|
user_message: LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED,
|
|
message_received: LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED,
|
|
user_message_received: LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED,
|
|
turn_start: LIFECYCLE_EVENT_TYPES.TURN_STARTED,
|
|
turn_started: LIFECYCLE_EVENT_TYPES.TURN_STARTED,
|
|
request_user_input: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
|
user_input_request: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
|
user_input_requested: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
|
item_tool_request_user_input: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
|
item_tool_requestuserinput: LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED,
|
|
turn_complete: LIFECYCLE_EVENT_TYPES.TURN_COMPLETED,
|
|
turn_completed: LIFECYCLE_EVENT_TYPES.TURN_COMPLETED,
|
|
turn_error: LIFECYCLE_EVENT_TYPES.TURN_FAILED,
|
|
turn_failed: LIFECYCLE_EVENT_TYPES.TURN_FAILED,
|
|
});
|
|
|
|
function isObject(value) {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function cleanText(value, maxLength = 4000) {
|
|
return typeof value === 'string' ? value.trim().slice(0, maxLength) : '';
|
|
}
|
|
|
|
function normalizeTimestamp(value, now) {
|
|
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
const parsed = new Date(value);
|
|
if (Number.isFinite(parsed.getTime())) return parsed.toISOString();
|
|
}
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const parsed = new Date(value);
|
|
if (Number.isFinite(parsed.getTime())) return parsed.toISOString();
|
|
}
|
|
return new Date(now()).toISOString();
|
|
}
|
|
|
|
function normalizeLifecycleEvent(input, options = {}) {
|
|
if (!isObject(input)) return null;
|
|
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
const rawType = cleanText(input.type || input.eventType || input.event, 80).toLowerCase();
|
|
let type = EVENT_TYPE_ALIASES[rawType] || rawType;
|
|
const outcome = cleanText(input.outcome || input.status, 80).toLowerCase();
|
|
if (type === LIFECYCLE_EVENT_TYPES.TURN_COMPLETED && /^(failed|failure|error|errored)$/.test(outcome)) {
|
|
type = LIFECYCLE_EVENT_TYPES.TURN_FAILED;
|
|
}
|
|
if (!SUPPORTED_EVENT_TYPES.has(type)) {
|
|
return { valid: false, reason: 'unsupported_event', type: type || null };
|
|
}
|
|
|
|
const errorValue = input.error;
|
|
const error = typeof errorValue === 'string'
|
|
? cleanText(errorValue)
|
|
: (isObject(errorValue) ? cleanText(errorValue.message) : '');
|
|
return {
|
|
valid: true,
|
|
type,
|
|
turnId: cleanText(input.turnId || input.turn_id, 128) || null,
|
|
eventId: cleanText(input.eventId || input.event_id || input.id, 160) || null,
|
|
occurredAt: normalizeTimestamp(input.occurredAt || input.timestamp, now),
|
|
outcome,
|
|
pendingUserInput: input.pendingUserInput === true,
|
|
structured: input.structured !== false,
|
|
trackingEnabled: input.trackingEnabled,
|
|
error: error || null,
|
|
};
|
|
}
|
|
|
|
function ignoredResult(sessionId, event, reason, extra = {}) {
|
|
return {
|
|
ok: true,
|
|
handled: false,
|
|
changed: false,
|
|
ignored: true,
|
|
reason,
|
|
sessionId,
|
|
eventType: event?.type || null,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function normalizeHandleArguments(sessionOrEnvelope, maybeEvent) {
|
|
if (typeof sessionOrEnvelope === 'string') {
|
|
return { sessionId: cleanText(sessionOrEnvelope, 128), event: maybeEvent };
|
|
}
|
|
if (!isObject(sessionOrEnvelope)) return { sessionId: '', event: maybeEvent };
|
|
const sessionId = cleanText(
|
|
sessionOrEnvelope.sessionId || sessionOrEnvelope.conversationId || sessionOrEnvelope.session_id,
|
|
128,
|
|
);
|
|
return {
|
|
sessionId,
|
|
event: isObject(sessionOrEnvelope.event) ? sessionOrEnvelope.event : sessionOrEnvelope,
|
|
};
|
|
}
|
|
|
|
function trackingEnabledFrom(value) {
|
|
if (typeof value === 'boolean') return value;
|
|
if (!isObject(value)) return null;
|
|
if (typeof value.enabled === 'boolean') return value.enabled;
|
|
if (typeof value.trackingEnabled === 'boolean') return value.trackingEnabled;
|
|
if (typeof value.taskTracking?.enabled === 'boolean') return value.taskTracking.enabled;
|
|
if (typeof value.task?.taskTracking?.enabled === 'boolean') return value.task.taskTracking.enabled;
|
|
return null;
|
|
}
|
|
|
|
function serviceEventFrom(event) {
|
|
const output = {
|
|
type: event.type,
|
|
occurredAt: event.occurredAt,
|
|
};
|
|
if (event.turnId) output.turnId = event.turnId;
|
|
if (event.outcome) output.outcome = event.outcome;
|
|
if (event.pendingUserInput) output.pendingUserInput = true;
|
|
if (event.error) output.error = event.error;
|
|
return output;
|
|
}
|
|
|
|
function eventIdentity(event) {
|
|
if (event.eventId) return `event:${event.eventId}`;
|
|
if (event.turnId) return `turn:${event.turnId}:${event.type}`;
|
|
return '';
|
|
}
|
|
|
|
function createTaskBoardLifecycle(deps = {}, extraOptions = {}) {
|
|
const directService = isObject(deps) && typeof deps.recordLifecycleEvent === 'function' ? deps : null;
|
|
const options = directService
|
|
? (isObject(extraOptions) ? extraOptions : {})
|
|
: { ...(isObject(deps) ? deps : {}), ...(isObject(extraOptions) ? extraOptions : {}) };
|
|
const taskBoardService = directService || options.taskBoardService || options.service;
|
|
if (!taskBoardService || typeof taskBoardService.recordLifecycleEvent !== 'function') {
|
|
throw new TypeError('createTaskBoardLifecycle 需要注入 TaskBoardService.recordLifecycleEvent');
|
|
}
|
|
|
|
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
const trackingChecker = typeof options.isTrackingEnabled === 'function'
|
|
? options.isTrackingEnabled
|
|
: null;
|
|
const logger = options.logger || null;
|
|
const queues = new Map();
|
|
const identitiesBySession = new Map();
|
|
|
|
function enqueue(sessionId, operation) {
|
|
const previous = queues.get(sessionId) || Promise.resolve();
|
|
const next = previous.catch(() => undefined).then(operation);
|
|
queues.set(sessionId, next);
|
|
return next.finally(() => {
|
|
if (queues.get(sessionId) === next) queues.delete(sessionId);
|
|
});
|
|
}
|
|
|
|
function hasIdentity(sessionId, identity) {
|
|
return Boolean(identity && identitiesBySession.get(sessionId)?.has(identity));
|
|
}
|
|
|
|
function rememberIdentity(sessionId, identity) {
|
|
if (!identity) return;
|
|
let identities = identitiesBySession.get(sessionId);
|
|
if (!identities) {
|
|
identities = new Set();
|
|
identitiesBySession.set(sessionId, identities);
|
|
}
|
|
identities.add(identity);
|
|
while (identities.size > 256) identities.delete(identities.values().next().value);
|
|
}
|
|
|
|
function logFailure(error, sessionId, event) {
|
|
if (!logger) return;
|
|
const payload = { sessionId, eventType: event?.type || '', error: cleanText(error?.message || error) };
|
|
if (typeof logger === 'function') logger('error', 'task_board_lifecycle_failed', payload);
|
|
else if (typeof logger.error === 'function') logger.error('task_board_lifecycle_failed', payload);
|
|
}
|
|
|
|
async function processEvent(sessionId, input) {
|
|
const event = normalizeLifecycleEvent(input, { now });
|
|
if (!event) return ignoredResult(sessionId, null, 'invalid_event');
|
|
if (!event.valid) return ignoredResult(sessionId, event, event.reason);
|
|
if (event.trackingEnabled === false) return ignoredResult(sessionId, event, 'task_tracking_disabled');
|
|
if (event.type === LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED && !event.structured) {
|
|
return ignoredResult(sessionId, event, 'unstructured_user_input');
|
|
}
|
|
if (trackingChecker) {
|
|
const enabled = trackingEnabledFrom(await trackingChecker(sessionId, event));
|
|
if (enabled === false) return ignoredResult(sessionId, event, 'task_tracking_disabled');
|
|
}
|
|
const identity = eventIdentity(event);
|
|
if (hasIdentity(sessionId, identity)) {
|
|
return ignoredResult(sessionId, event, 'duplicate_event');
|
|
}
|
|
|
|
let serviceResult;
|
|
try {
|
|
serviceResult = await taskBoardService.recordLifecycleEvent(sessionId, serviceEventFrom(event));
|
|
} catch (error) {
|
|
if (error?.code === 'task_tracking_disabled') {
|
|
return ignoredResult(sessionId, event, 'task_tracking_disabled');
|
|
}
|
|
logFailure(error, sessionId, event);
|
|
throw error;
|
|
}
|
|
rememberIdentity(sessionId, identity);
|
|
return {
|
|
ok: true,
|
|
handled: serviceResult?.ignored !== true,
|
|
changed: serviceResult?.changed === true,
|
|
ignored: serviceResult?.ignored === true,
|
|
reason: serviceResult?.reason || null,
|
|
sessionId,
|
|
eventType: event.type,
|
|
...(serviceResult?.task ? { task: serviceResult.task } : {}),
|
|
};
|
|
}
|
|
|
|
function handleEvent(sessionOrEnvelope, maybeEvent) {
|
|
const { sessionId, event } = normalizeHandleArguments(sessionOrEnvelope, maybeEvent);
|
|
if (!sessionId) return Promise.resolve(ignoredResult('', null, 'session_id_missing'));
|
|
return enqueue(sessionId, () => processEvent(sessionId, event));
|
|
}
|
|
|
|
function clearSession(sessionIdValue) {
|
|
const sessionId = cleanText(sessionIdValue, 128);
|
|
if (!sessionId) return Promise.resolve(false);
|
|
identitiesBySession.delete(sessionId);
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
const eventHandler = (type) => (sessionId, event = {}) => handleEvent(sessionId, { ...event, type });
|
|
return Object.freeze({
|
|
handleEvent,
|
|
recordLifecycleEvent: handleEvent,
|
|
clearSession,
|
|
onUserMessageReceived: eventHandler(LIFECYCLE_EVENT_TYPES.USER_MESSAGE_RECEIVED),
|
|
onTurnStarted: eventHandler(LIFECYCLE_EVENT_TYPES.TURN_STARTED),
|
|
onUserInputRequested: eventHandler(LIFECYCLE_EVENT_TYPES.USER_INPUT_REQUESTED),
|
|
onTurnCompleted: eventHandler(LIFECYCLE_EVENT_TYPES.TURN_COMPLETED),
|
|
onTurnFailed: eventHandler(LIFECYCLE_EVENT_TYPES.TURN_FAILED),
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
LIFECYCLE_EVENT_TYPES,
|
|
createTaskBoardLifecycle,
|
|
normalizeLifecycleEvent,
|
|
};
|