Files
LWLT-AIBOT/tools/run_lifecycle_fixture_via_platform.mjs
2026-08-25 10:33:29 +08:00

339 lines
18 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
const DEFAULT_CDP = '/Users/inmanx/.agents/skills/chrome-cdp/scripts/cdp.mjs';
const DEFAULT_PLATFORM_TARGET = 'D484CA70';
const DEFAULT_ERP_TARGET = 'DA600CC0';
const CREATE_ACTIONS = new Set([
'team_order_create',
'team_order_batch_create',
'shared_plan_create',
'shared_child_order_create'
]);
function parseArgs(argv) {
const args = {
fixture: '',
taskId: '',
confirm: false,
allowLiveWrite: false,
platformTarget: DEFAULT_PLATFORM_TARGET,
erpTarget: DEFAULT_ERP_TARGET,
cdp: process.env.CDP_CLI || DEFAULT_CDP,
expectedRunId: '',
parseTimeoutMs: 30_000,
resultTimeoutMs: 120_000
};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (value === '--fixture') args.fixture = argv[++index] || '';
else if (value === '--task-id') args.taskId = argv[++index] || '';
else if (value === '--confirm') args.confirm = true;
else if (value === '--allow-live-write') args.allowLiveWrite = true;
else if (value === '--platform-target') args.platformTarget = argv[++index] || '';
else if (value === '--erp-target') args.erpTarget = argv[++index] || '';
else if (value === '--cdp') args.cdp = argv[++index] || '';
else if (value === '--expected-run-id') args.expectedRunId = argv[++index] || '';
else if (value === '--parse-timeout-ms') args.parseTimeoutMs = Number(argv[++index]);
else if (value === '--result-timeout-ms') args.resultTimeoutMs = Number(argv[++index]);
else throw new Error(`unknown argument: ${value}`);
}
if (!args.fixture) throw new Error('--fixture is required');
if (!args.expectedRunId) throw new Error('--expected-run-id is required');
if (!Number.isFinite(args.parseTimeoutMs) || args.parseTimeoutMs < 1_000) throw new Error('--parse-timeout-ms must be at least 1000');
if (!Number.isFinite(args.resultTimeoutMs) || args.resultTimeoutMs < 5_000) throw new Error('--result-timeout-ms must be at least 5000');
return args;
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function cdp(args, command, target, ...rest) {
return execFileSync(process.execPath, [args.cdp, command, target, ...rest], {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024
}).trim();
}
function evaluate(args, target, expression) {
return cdp(args, 'eval', target, expression);
}
function parseJsonOutput(value, label) {
try {
return JSON.parse(String(value || '').trim());
} catch (error) {
throw new Error(`${label} returned non-JSON output: ${String(value || '').slice(0, 500)}`);
}
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function coreOperation(operation, expected) {
const copy = structuredClone(operation);
if (copy.data && expected?.data) {
if (!Object.hasOwn(expected.data, 'attachments')) delete copy.data.attachments;
if (!Object.hasOwn(expected.data, 'system_defaults')) delete copy.data.system_defaults;
}
return copy;
}
function expectedPreConfirmationOperation(operation) {
const copy = structuredClone(operation);
if (!CREATE_ACTIONS.has(copy?.action) && copy?.source?.test_context) {
copy.source.test_context.allow_live_write = false;
}
return copy;
}
function validateFixtureSafety(operation, expectedRunId, allowLiveWrite = false) {
const context = operation?.source?.test_context || {};
const refs = operation?.data?.existing_refs || {};
const data = operation?.data || {};
const errors = [];
if (CREATE_ACTIONS.has(operation?.action)) {
if (allowLiveWrite) errors.push('creation_must_not_use_allow_live_write_flag');
const dates = Array.isArray(data.departure_dates) ? data.departure_dates : [];
if (!dates.length || dates.some((date) => !/^2026-09-\d{2}$/.test(String(date || '')))) errors.push('creation_dates_outside_2026_09');
const contextDates = Array.isArray(context.target_dates) ? context.target_dates.map(String) : [];
if (context.run_id !== expectedRunId) errors.push('creation_run_id_mismatch');
if (context.marker !== 'TEST-202609') errors.push('creation_marker_mismatch');
if (context.account !== '测试ai员工账号') errors.push('creation_account_mismatch');
if (!String(context.native_baseline_id || '').trim()) errors.push('creation_native_baseline_missing');
if (context.live_window !== '2026-09') errors.push('creation_live_window_mismatch');
if (dates.some((date) => !contextDates.includes(String(date)))) errors.push('creation_target_dates_mismatch');
if (context.allow_live_write === true) errors.push('creation_must_not_self_authorize_live_write');
const suffix = String(data.order_number?.suffix || '');
const parent = String(data.existing_refs?.parent_group_no || data.parent_group_no || '');
if (operation.action === 'shared_child_order_create') {
if (!parent.includes('TEST-202609') || !String(data.special_requests || '').includes('TEST-202609')) errors.push('shared_child_creation_marker_mismatch');
} else if (!suffix.includes('TEST-202609')) errors.push('creation_suffix_marker_mismatch');
const splitOrder = data.split_order && typeof data.split_order === 'object' && !Array.isArray(data.split_order)
? data.split_order : {};
const sharedBatchSplitProbe = operation.action === 'shared_plan_create'
&& dates.length >= 2
&& (splitOrder.customer !== undefined || splitOrder.passenger_counts !== undefined);
if (sharedBatchSplitProbe) {
if (context.phase !== 'shared_batch_split_order_probe') errors.push('shared_batch_probe_phase_mismatch');
}
return errors;
}
if (context.run_id !== expectedRunId) errors.push('run_id_mismatch');
if (context.marker !== 'TEST-202609' || refs.marker !== 'TEST-202609') errors.push('marker_mismatch');
if (context.account !== '测试ai员工账号' || refs.owner_account !== '测试ai员工账号') errors.push('owner_mismatch');
const deleteLiveWrite = operation?.action === 'order_delete';
if (deleteLiveWrite && context.allow_live_write !== true) {
errors.push('delete_fixture_allow_live_write_must_be_true');
}
if (!deleteLiveWrite && context.allow_live_write === true && !allowLiveWrite) {
errors.push('non_delete_live_write_requires_allow_live_write_flag');
}
if (!deleteLiveWrite && context.allow_live_write !== true && allowLiveWrite) {
errors.push('allow_live_write_flag_requires_fixture_authorization');
}
if (context.live_window !== '2026-09') errors.push('live_window_mismatch');
const targetDate = String(refs.departure_date || '');
if (targetDate && !/^2026-09-\d{2}$/.test(targetDate)) errors.push('target_date_outside_2026_09');
if (!Array.isArray(context.created_refs) || !context.created_refs.some((item) => (
item?.identifier === refs.identifier
&& String(item?.tid || '') === String(refs.tid || '')
&& (!refs.ddid || String(item?.ddid || '') === String(refs.ddid))
))) errors.push('target_not_in_created_refs');
return errors;
}
function localTaskExpression(taskId) {
return `(()=>{const t=taskStore.find(x=>x.task_id===${JSON.stringify(taskId)});return JSON.stringify(t&&{task_id:t.task_id,status:t.status,stage:t.stage,message:t.message,parser:t.parse_response?.parser_prompt_version,action:t.operation?.action,data_keys:Object.keys(t.operation?.data||{}),failure:t.failure,success_receipt:t.success_receipt})})()`;
}
function exactRemoteTaskExpression(taskId) {
return `(async()=>{const response=await fetch('/api/tasks/'+encodeURIComponent(${JSON.stringify(taskId)}),{credentials:'same-origin'});if(!response.ok)throw new Error('exact_task_http_'+response.status);const payload=await response.json();const t=payload?.task;if(!t)throw new Error('exact_task_missing');const index=taskStore.findIndex(x=>x.task_id===t.task_id);if(index>=0)taskStore[index]=t;else taskStore.unshift(t);return JSON.stringify({task_id:t.task_id,status:t.status,stage:t.stage,message:t.message,parser:t.parse_response?.parser_prompt_version,action:t.operation?.action,data_keys:Object.keys(t.operation?.data||{}),failure:t.failure,success_receipt:t.success_receipt})})()`;
}
async function waitForParsedTask(args, taskId) {
const startedAt = Date.now();
let last = null;
while (Date.now() - startedAt < args.parseTimeoutMs) {
try {
last = parseJsonOutput(evaluate(args, args.platformTarget, localTaskExpression(taskId)), 'task state');
} catch {
last = null;
}
if (last && !['parse_queued', 'parse_running'].includes(last.status)) return last;
try {
last = parseJsonOutput(evaluate(args, args.platformTarget, exactRemoteTaskExpression(taskId)), 'exact remote task state');
if (last && !['parse_queued', 'parse_running'].includes(last.status)) return last;
} catch {
// The next exact read retries the same durable task; it never creates a duplicate.
}
await wait(500);
}
throw new Error(`task ${taskId} did not leave parse queue within ${args.parseTimeoutMs}ms; do not create a duplicate task`);
}
async function waitForCreatedTaskId(args, previousTaskId) {
const startedAt = Date.now();
let latest = '';
while (Date.now() - startedAt < args.parseTimeoutMs) {
try {
const state = parseJsonOutput(evaluate(
args,
args.platformTarget,
`JSON.stringify({task_id:String(currentTaskId||'')})`
), 'created task state');
latest = String(state?.task_id || '');
if (latest && latest !== previousTaskId) return latest;
} catch {
// The scheduled platform POST is still pending; do not schedule another.
}
await wait(500);
}
throw new Error(`scheduled task creation did not publish a new task id within ${args.parseTimeoutMs}ms; do not create a duplicate, query the expected run id`);
}
function terminalExtensionResult(result) {
const status = String(result?.status || '');
const stage = String(result?.stage || '');
return /(?:completed|blocked|uncertain|pending)$/.test(status)
|| /(?:completed|blocked|uncertain|reconciliation)$/.test(stage);
}
async function waitForExtensionResult(args, taskId) {
const startedAt = Date.now();
let latest = null;
while (Date.now() - startedAt < args.resultTimeoutMs) {
const expression = `(async()=>JSON.stringify(await sendToExtension('GET_TASK_RESULT',{task_id:${JSON.stringify(taskId)}},15000)))()`;
try {
const response = parseJsonOutput(evaluate(args, args.platformTarget, expression), 'extension result');
latest = response?.result || null;
if (latest && terminalExtensionResult(latest)) return latest;
} catch {
// A transient bridge/page timeout does not authorize a resubmission.
}
await wait(2_000);
}
throw new Error(`task ${taskId} has no terminal extension result after ${args.resultTimeoutMs}ms; do not resubmit, inspect the same task`);
}
async function publishExistingResult(args) {
try {
evaluate(args, args.platformTarget, '(()=>{void (async()=>{await pollAllTaskResults();await syncRemoteTasks()})().catch(()=>{});return "scheduled"})()');
} catch {
// The extension result remains durable even if this UI synchronization times out.
}
}
function resultSummary(taskId, parsed, expectedHash, actualHash, result, platformTask) {
const report = result?.report || result || {};
return {
task_id: taskId,
action: parsed.action,
canonical_core_sha256: actualHash,
expected_core_sha256: expectedHash,
canonical_core_exact: expectedHash === actualHash,
platform: platformTask,
extension: {
execution_id: result?.execution_id || report.execution_id || '',
status: result?.status || '',
stage: result?.stage || '',
message: result?.message || '',
write_attempted: result?.write_attempted === true,
no_erp_write: result?.no_erp_write === true,
blockers: result?.blockers || report.blockers || [],
resolved_refs: result?.resolved_refs || report.resolved_refs || null,
before_snapshot: result?.before_snapshot || report.before_snapshot || null,
changes: report.changes || [],
preflight: result?.preflight || report.preflight || null,
server_response: result?.server_response || report.server_response || null,
requery: result?.requery || report.requery || null,
erp_receipt: result?.erp_receipt || report.erp_receipt || null,
verification: result?.verification || report.verification || null,
reconciliation: result?.reconciliation || report.reconciliation || null,
submit: result?.submit || report.submit || null,
side_effects: result?.side_effects || report.side_effects || [],
warnings: result?.warnings || report.warnings || [],
manual_review_required: result?.manual_review_required === true || report.manual_review_required === true
}
};
}
const args = parseArgs(process.argv.slice(2));
const rawFixture = await readFile(args.fixture, 'utf8');
const fixture = JSON.parse(rawFixture);
const fixtureSafetyErrors = validateFixtureSafety(fixture, args.expectedRunId, args.allowLiveWrite);
if (fixtureSafetyErrors.length) throw new Error(`fixture safety check failed: ${fixtureSafetyErrors.join(',')}`);
let taskId = String(args.taskId || '');
if (!taskId) {
const encoded = Buffer.from(rawFixture).toString('base64');
const createExpression = `(()=>{const input=document.querySelector('#rawInstruction');if(!input)throw new Error('rawInstruction_missing');const previousTaskId=String(currentTaskId||'');input.value=new TextDecoder().decode(Uint8Array.from(atob(${JSON.stringify(encoded)}),c=>c.charCodeAt(0)));input.dispatchEvent(new Event('input',{bubbles:true}));void createTask().catch(()=>{});return JSON.stringify({scheduled:true,previous_task_id:previousTaskId})})()`;
const scheduled = parseJsonOutput(evaluate(args, args.platformTarget, createExpression), 'task creation scheduling');
if (scheduled.scheduled !== true) throw new Error('task creation was not scheduled');
taskId = await waitForCreatedTaskId(args, String(scheduled.previous_task_id || ''));
}
const parsedState = await waitForParsedTask(args, taskId);
if (parsedState.status !== 'awaiting_confirmation') {
console.log(JSON.stringify({ task_id: taskId, status: parsedState.status, parsed_state: parsedState }, null, 2));
process.exitCode = 2;
} else {
const expectedPreConfirmation = expectedPreConfirmationOperation(fixture);
const expectedCore = coreOperation(expectedPreConfirmation, fixture);
const expectedHash = sha256(JSON.stringify(expectedCore));
const expectedHasAttachments = Object.hasOwn(fixture.data || {}, 'attachments');
const expectedHasSystemDefaults = Object.hasOwn(fixture.data || {}, 'system_defaults');
const hashExpression = `(async()=>{const t=taskStore.find(x=>x.task_id===${JSON.stringify(taskId)});if(!t)throw new Error('task_not_found');const a=structuredClone(t.operation);${expectedHasAttachments ? '' : 'delete a.data.attachments;'}${expectedHasSystemDefaults ? '' : 'delete a.data.system_defaults;'}const d=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(a)));return [...new Uint8Array(d)].map(b=>b.toString(16).padStart(2,'0')).join('')})()`;
const actualHash = evaluate(args, args.platformTarget, hashExpression);
if (actualHash !== expectedHash) throw new Error(`canonical core mismatch for ${taskId}: expected ${expectedHash}, actual ${actualHash}; task remains unconfirmed`);
const guardExpression = `(async()=>{const t=taskStore.find(x=>x.task_id===${JSON.stringify(taskId)});return JSON.stringify({task_id:t?.task_id,status:t?.status,action:t?.operation?.action,parser:t?.parse_response?.parser_prompt_version,org_automation:organizationAutomationEnabled,required_version:REQUIRED_EXTENSION_VERSION,extension:await sendToExtension('PING')})})()`;
const guard = parseJsonOutput(evaluate(args, args.platformTarget, guardExpression), 'platform guard');
const erpGuard = guard.extension?.erp_session || null;
const guardErrors = [];
if (guard.task_id !== taskId || guard.status !== 'awaiting_confirmation') guardErrors.push('task_not_awaiting_confirmation');
if (guard.action !== fixture.action || guard.parser !== 'canonical-json-v1') guardErrors.push('canonical_task_identity_mismatch');
if (guard.org_automation !== false) guardErrors.push('organization_automation_must_be_false');
if (!guard.extension?.ok || guard.extension.version !== guard.required_version || guard.extension.erp_automation_enabled !== true) guardErrors.push('extension_runtime_guard_failed');
if (!erpGuard?.ok || !erpGuard.tab_present || !erpGuard.account_matched || erpGuard.login_or_permission_error) {
guardErrors.push('erp_test_account_not_present');
}
if (guardErrors.length) throw new Error(`runtime safety check failed: ${guardErrors.join(',')}; task remains unconfirmed`);
if (!args.confirm) {
console.log(JSON.stringify({
task_id: taskId,
status: parsedState.status,
action: fixture.action,
canonical_core_sha256: actualHash,
canonical_core_exact: true,
guard,
erp_guard: erpGuard,
confirmed: false
}, null, 2));
} else {
evaluate(args, args.platformTarget, `(()=>{const t=taskStore.find(x=>x.task_id===${JSON.stringify(taskId)});if(!t)throw new Error('task_not_found');if(t.status!=='awaiting_confirmation')throw new Error('task_not_awaiting_confirmation');void confirmAndSubmitToErpPlugin(t).catch(()=>{});return JSON.stringify({submitted:true,task_id:t.task_id})})()`);
try {
cdp(args, 'evalraw', args.erpTarget, 'Page.bringToFront', '{}');
} catch {
// Foregrounding only prevents background timer throttling; it is not a write prerequisite.
}
const result = await waitForExtensionResult(args, taskId);
await publishExistingResult(args);
let platformTask = null;
try {
platformTask = parseJsonOutput(evaluate(args, args.platformTarget, localTaskExpression(taskId)), 'final task state');
} catch {
platformTask = { task_id: taskId, status: 'ui_sync_unavailable' };
}
const summary = resultSummary(taskId, fixture, expectedHash, actualHash, result, platformTask);
console.log(JSON.stringify(summary, null, 2));
if (!/(?:completed)$/.test(String(result.status || ''))) process.exitCode = 2;
}
}