Files
LWLT-AIBOT/control-plane/src/operation-timing.ts

247 lines
7.3 KiB
TypeScript

const OPERATION_TIMING_SCHEMA_VERSION = 'ltjt-operation-timing-v1';
const CODE_PATTERN = /^[a-z][a-z0-9_.:-]{0,79}$/i;
const MAX_STAGES = 64;
const MAX_DETAILS = 48;
const MAX_COUNTERS = 24;
const MAX_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
const ALLOWED_STAGE_CODES = new Set([
'auto_executor',
'automation_disabled',
'batch_fallback',
'batch_fallback_error',
'browser_dry_run',
'browser_execution',
'cancelled',
'capability_check',
'erp_keepalive_degraded',
'erp_resolution',
'execution_gate',
'execution_timeout',
'extension_error',
'lifecycle_business_blocked',
'lifecycle_completed',
'lifecycle_live',
'lifecycle_live_submit',
'lifecycle_preflight',
'lifecycle_reconciliation',
'lifecycle_route_preparation',
'live_submit',
'open_order_form',
'open_team_batch_form',
'post_resolution_execution_gate',
'preflight',
'queued',
'reconciliation',
'unknown_stage',
'validation',
'verification',
'wait_order_form',
'wait_team_batch_form'
]);
const ALLOWED_STATUSES = new Set([
'accepted',
'blocked',
'cancelled',
'completed',
'dry_run',
'execution_uncertain',
'failed',
'mixed',
'reconciliation_pending',
'running',
'saved_unverified',
'skipped',
'unknown'
]);
const ALLOWED_ACTION_CODES = new Set([
'cleanup_completed_lifecycle_dialog',
'create_split_child_live',
'create_split_parent_live',
'export_confirmation_sources',
'inspect_operation_context',
'live_submit_approved',
'live_submit_lifecycle_operation',
'live_submit_team_batch_approved',
'open_order_form',
'open_team_batch_form',
'ping_order_frame',
'ping_team_batch_frame',
'preflight_lifecycle_operation',
'preflight_raw_instruction',
'preflight_team_batch_native',
'prepare_lifecycle_operation',
'resolve_lifecycle_operation',
'return_to_order_list',
'unknown_action',
'verify_lifecycle_operation',
'verify_order_marker',
'verify_team_batch_receipts'
]);
const ALLOWED_DETAIL_CODES = new Set([
'executor.erp_keepalive_setup',
'preflight_customer_restore',
'preflight_lookup.customer',
'preflight_lookup.parallel_wall',
'preflight_lookup.product',
'preflight_lookup.route',
'preflight_lookup.staff',
'preflight_product_linkage',
'preflight_submit_intercept',
'result_publish.prepare',
'storage_prune.local_history',
'storage_queue.accept_execution',
'storage_queue.execution_state',
'storage_queue.result_state',
'storage_read.accept_execution',
'storage_read.automation_setting',
'storage_read.execution_record',
'storage_read.execution_state',
'storage_read.result_state',
'storage_read.task_payload',
'storage_write.accept_execution',
'storage_write.execution_state',
'storage_write.last_frame',
'storage_write.last_live_submit',
'storage_write.last_preflight',
'storage_write.last_return_to_list',
'storage_write.last_verification',
'storage_write.result_state',
'tab_lookup.cache_hit',
'tab_lookup.create',
'tab_lookup.find_or_open',
'tab_lookup.host_permission',
'tab_lookup.query',
'tab_lookup.wait_ready'
]);
const ALLOWED_COUNTERS = new Set([
'cache_hit_count',
'call_count',
'created_count',
'execution_count',
'failure_count',
'frame_count',
'protected_root_count',
'quiet_wait_ms',
'removed_execution_count',
'removed_result_count',
'removed_root_count',
'removed_task_count',
'removed_timing_count',
'request_count',
'result_count',
'retained_root_count',
'sample_count',
'stable_sample_count',
'submit_count',
'tab_count',
'task_count',
'timing_count'
]);
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function code(value: unknown, fallback = ''): string {
const normalized = String(value || '').trim();
return CODE_PATTERN.test(normalized) ? normalized : fallback;
}
function statusCode(value: unknown, fallback = 'unknown'): string {
const normalized = code(value);
return ALLOWED_STATUSES.has(normalized) ? normalized : fallback;
}
function stageCode(value: unknown): string {
const normalized = code(value);
return ALLOWED_STAGE_CODES.has(normalized) ? normalized : '';
}
function stepCode(value: unknown): string {
const normalized = code(value);
if (normalized === 'script_injection') return normalized;
if (ALLOWED_DETAIL_CODES.has(normalized)) return normalized;
const matched = normalized.match(/^(action_roundtrip|action_runtime|network_wait)\.(.+)$/u);
return matched && ALLOWED_ACTION_CODES.has(matched[2]) ? normalized : '';
}
function duration(value: unknown): number {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return 0;
return Math.min(MAX_DURATION_MS, Math.round(number));
}
function count(value: unknown): number {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return 0;
return Math.min(Number.MAX_SAFE_INTEGER, Math.round(number));
}
function sanitizeCounters(value: unknown): Record<string, number> | null {
const input = record(value);
if (!input) return null;
const output: Record<string, number> = {};
for (const [rawKey, rawValue] of Object.entries(input).slice(0, MAX_COUNTERS)) {
const key = code(rawKey);
const number = Number(rawValue);
if (!ALLOWED_COUNTERS.has(key) || !Number.isFinite(number) || number < 0) continue;
output[key] = Math.min(Number.MAX_SAFE_INTEGER, Math.round(number));
}
return Object.keys(output).length ? output : null;
}
function sanitizeDetail(value: unknown): Record<string, unknown> | null {
const input = record(value);
if (!input) return null;
const step = stepCode(input.step);
if (!step) return null;
const counters = sanitizeCounters(input.counters);
return {
step,
status: statusCode(input.status),
duration_ms: duration(input.duration_ms),
...(counters ? { counters } : {})
};
}
function sanitizeStage(value: unknown): Record<string, unknown> | null {
const input = record(value);
if (!input) return null;
const stage = stageCode(input.stage);
if (!stage) return null;
const details = (Array.isArray(input.details) ? input.details : [])
.map(sanitizeDetail)
.filter((detail): detail is Record<string, unknown> => Boolean(detail))
.slice(0, MAX_DETAILS);
const truncatedDetailCount = count(input.truncated_detail_count);
return {
stage,
status: statusCode(input.status),
duration_ms: duration(input.duration_ms),
...(details.length ? { details } : {}),
...(truncatedDetailCount ? { truncated_detail_count: truncatedDetailCount } : {})
};
}
export function sanitizeOperationTiming(value: unknown): Record<string, unknown> | null {
const input = record(value);
if (!input || input.schema_version !== OPERATION_TIMING_SCHEMA_VERSION) return null;
const stages = (Array.isArray(input.stages) ? input.stages : [])
.map(sanitizeStage)
.filter((stage): stage is Record<string, unknown> => Boolean(stage))
.slice(0, MAX_STAGES);
const truncatedStageCount = count(input.truncated_stage_count);
return {
schema_version: OPERATION_TIMING_SCHEMA_VERSION,
status: statusCode(input.status),
total_ms: duration(input.total_ms),
stage_count: Math.max(stages.length, count(input.stage_count)),
stages,
...(truncatedStageCount ? { truncated_stage_count: truncatedStageCount } : {})
};
}
export { OPERATION_TIMING_SCHEMA_VERSION };