diff --git a/.project-docs/30-worklog/tasks/20260915-dispatch-readiness-01a0858b.md b/.project-docs/30-worklog/tasks/20260915-dispatch-readiness-01a0858b.md new file mode 100644 index 0000000..3eb2d01 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260915-dispatch-readiness-01a0858b.md @@ -0,0 +1,27 @@ +# ERP list readiness and late dispatch results + +## Scope and gate +- Feature task; main thread only, no subagents. Branch `main`, sole worktree `/Users/andy/IdeaProjects/LWLT-AIBOT`, base `0282c30a2aa66435a193b96b17c0022420d881b3`. +- Existing `.idea/` is unrelated and preserved. Untracked release ZIP 0.5.183 is the known previous delivery and will be preserved byte-for-byte if archived. Overlap: Clear. No other worktree or overlapping source changes. +- User asks to fix the diagnosed list-readiness and dispatch-result discrepancy. Local source/tests/release work only; no ERP access/write, task replay, deployment, restart, extension reload or external sending. + +## Evidence and plan +- User-supplied records prove the same execution was accepted near the platform's four-second acknowledgement timeout, then stopped at `erp_resolution` with `native_list_search_not_ready`. Platform retained dispatch uncertainty until maintenance expiry. Exact missing page condition and cause of acknowledgement delay were not recorded. +- Reacquire the current main-frame document while waiting for the expected list page; preserve complete-page/native-loader/search-button prerequisites and native search request verification. Add value-free readiness diagnostics. Do not retry business writes. +- Allow authenticated same-task/execution/connection late results to resolve only dispatch-origin uncertainty when explicit read-only resolution failure proves no write. Preserve completed/cancelled/post-write uncertainty guards, including old timed-out dispatch tasks. Prevent provisional dispatch timeouts from replacing already-observed plugin progress. +- Read stored plugin results while dispatch-origin uncertainty is pending; do not invoke ERP reconciliation or execute/re-dispatch tasks. Validate delay, missing/stale/wrong pages, outcome ordering, evidence conflicts and owner/execution boundaries. +- Synchronize extension version, platform minimum, mapping, release ZIP/manifest and local verification evidence. Run required repository/type/control-plane/legacy/build checks. Production acceptance remains separate. + +## Results +- Native list readiness now reacquires the current main-frame document at each sample, waits for the same expected path plus complete document/native loader/enabled attached search button, and reports value-free missing conditions, elapsed time, sample count and document replacements. Search consumers use the returned current document; replacement during native Ajax search stops before using stale results. Existing exact-request/HTTP completion verification remains. Worker output distinguishes readiness from not-found/login suggestions. +- Platform acceptance timeout is 10 seconds (previously four). Existing pending dispatch tasks read only stored plugin results; late progress does not reset them to executable/running. Expired dispatch tasks offer manual “读取插件已保存结果” instead of invoking the plugin's incompatible post-write reconciliation operation. Genuine post-write uncertainty retains its manual boundary. API rejection preserves platform uncertainty instead of replacing it with an unaccepted local result. +- Service ingestion may close only dispatch-origin uncertainty (including maintenance-expired dispatch records) using the same task/execution/original connection and complete `erp_readonly_resolution` page proof with no root/page/preflight write or save evidence. Other terminal states, bare flags, contradictory receipts/requests, changed connection/execution/actor and real post-write uncertainty remain blocked. A provisional timeout cannot overwrite already-observed plugin progress. Accepted late failure carries reconciled/no-write facts; an exact duplicate is idempotent and changed terminal results remain immutable. No execution/replay path is added. +- Regression evidence: both same-path Document replacement scenarios fail against the pre-change committed adapter with `native_list_search_not_ready` and pass against the current adapter. 32 new tests cover native readiness/result handling, five-second acceptance, true timeout without redispatch, local-only result sync, original-context constraints, transaction commit/rollback, outcome ordering and duplicate ingestion. +- Full checks: control plane 239/239, legacy 402/402 (641 tests total), TypeScript check and build PASS. Legacy HTTP boundary tests required local loopback-listening permission; the first sandboxed run was blocked by EPERM for those two tests, then the full rerun passed. Clean isolated-source repository checks 10/10 PASS. Original worktree remains 8/10 only because of preserved pre-existing `.idea/`, `.DS_Store` and ignored historical log residue. +- Extension 0.5.184, platform minimum, lifecycle mapping, version assertions, active gate, README, versioned ZIP and release manifest synchronized. All 20 packaged files match source byte-for-byte. Original 0.5.183 ZIP and manifest preserved unchanged in the dated release archive. No input/Skill/DOCX/parser/database migration changes. +- Main-thread read-only final review: PASS. No subagent review claimed. Tests ran from a byte-verified source copy excluding non-example `.env` before reads; no ERP access, task mutation/replay, deployment, service restart, extension reload, external message, commit or push occurred. +- [Immutable validation summary](../../../archive/evidence/2026-09-15/dispatch-readiness-01a0858b/README.md). Production requires updating platform/service and the same installed extension to 0.5.184, retaining its local records; old tasks can sync only if original connection/execution evidence is retained. The exact missing readiness condition in the user's historical run remains unobserved; new diagnostics distinguish it. + +## Promotion candidates +- Preserve the distinction between provisional dispatch uncertainty and witnessed ERP writes. Only correlated explicit read-only failure evidence closes the former; no retry or blanket terminal-state override. Target: canonical execution lifecycle documentation after integration acceptance. +- Document readiness must refer to the current Document identity, not merely a URL retained by an old iframe document. Target: canonical browser-adapter guidance after integration. Current live failure's specific missing condition still requires runtime diagnostics. diff --git a/.project-docs/30-worklog/tasks/20260915-roster-header-aliases-01a0858b.md b/.project-docs/30-worklog/tasks/20260915-roster-header-aliases-01a0858b.md new file mode 100644 index 0000000..8f8d0b2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260915-roster-header-aliases-01a0858b.md @@ -0,0 +1,26 @@ +# Passenger roster header aliases and content diagnosis + +## Scope and gate +- Feature task on `main`, sole worktree `/Users/andy/IdeaProjects/LWLT-AIBOT`, base `0282c30a2aa66435a193b96b17c0022420d881b3`. Main thread only. +- Existing dispatch/readiness changes are the completed previous task in this conversation; preserve them. `.idea/` and existing ignored residue are unrelated and preserved. No other worktree or overlapping edits in the roster normalizer/tests. Overlap: Clear. +- User authorizes compatibility for two observed header aliases and read-only validation of the supplied workbook contents. No source workbook edits, ERP import/access, task submission, deployment, restart or external sending. +- Own roster normalizer, roster regression tests and this task record. No canonical memory or extension changes. + +## Evidence and plan +- Supplied XLS matches the reported attachment: 48128 bytes, SHA-256 `c11d17fd13607afc9c8a52bf2716a5e900a3a387137f88ba314e8dcf15e3d76d`. +- Existing local conversion succeeds. Actual normalizer reproduces `roster_workbook_header_not_found`: `Sheet1!H2` is 出生地点 and `K2` is 有效期至. Other ten required fields are recognized, no duplicate semantic headers. +- Add only the two approved exact aliases, preserve semantic uniqueness and fixed canonical output. Validate both aliases and duplicate/conflicting headers with synthetic records. +- Run full local normalization/content inspection on the original file, report remaining content problems without inventing passenger facts. Keep passenger values out of tests, task records and logs. +- Run required repository/type/control-plane/legacy/build checks; record results and any pre-existing check failures. + +## Results +- Normalizer `v1.5.1` accepts exact aliases 出生地点 → 出生地 and 有效期至 → 有效期. All 12 semantic source fields remain required and unique; canonical TSV remains the same 13 columns. Added synthetic equality and canonical/alias duplicate rejection coverage. No content normalization or ERP execution behavior was changed. +- Original workbook passed conversion and complete normalization without editing it: header row 2, data rows 3–22, 20 passengers, unique sequential indices 1–20. All 60 date cells and all canonical text/identifier projections were independently compared with the converted sheet; zero mismatches. Source hash remained unchanged. +- Names, English names, gender, birthplace, passport numbers, issue location, three date fields and remarks are populated for all 20 passengers. Phone is populated for 11; nine blank phones remain blank under existing rules. Merged remarks correctly project to their constituent passenger rows. No imported-field formulas need compatibility; the ordinary age column remains ignored. +- Exactly one leader marker has both name and phone. Both independent and shared-child Program parser routes passed with synthetic local-only lookup context, 20 rows and leader_contact. These calls only validated content/operation contracts; no task, database, ERP or external call was made. No additional content compatibility is needed for this file. +- Focused workbook tests 14/14; full control-plane 241/241 and legacy 402/402 (643 total); type check and build PASS. Repository checks 10/10 in a byte-verified isolated copy excluding secrets and pre-existing `.idea`/residue. Two legacy HTTP tests initially failed only because sandbox loopback listening was denied; approved loopback rerun passed all 402. Original unrelated files preserved. +- Main-thread final read-only review PASS. No subagents, source workbook edits, live ERP import, service restart, deployment, commit or push. This compatibility is server-side; the extension release is unaffected. Existing `npm run dev` process needs a user-authorized restart to load the new source. +- [Read-only evidence summary](../../../archive/evidence/2026-09-15/roster-header-aliases-01a0858b/README.md). + +## Promotion candidates +- Document accepted source aliases in canonical roster guidance after integration if needed; canonical ERP fields remain unchanged. diff --git a/LianSyn-platform/app-dispatch-recovery.test.mjs b/LianSyn-platform/app-dispatch-recovery.test.mjs new file mode 100644 index 0000000..f59b15c --- /dev/null +++ b/LianSyn-platform/app-dispatch-recovery.test.mjs @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); +function functionSource(name) { + const start = source.search(new RegExp(`(?:async )?function ${name}\\(`)); + assert.ok(start >= 0, name); + const rest = source.slice(start); + const next = rest.slice(1).search(/\n(?:async )?function /); + return next < 0 ? rest : rest.slice(0, next + 1); +} +const helperNames = ['isDispatchResultPending', 'isTaskPollable', 'pollTaskResult', 'persistExtensionTaskResult', 'reconcileTaskReceipt', 'handoffTaskToExtension']; + +function fixture({ result, state = 'reconciliation_pending', owner = true, acceptDelay = 0, resultError = false } = {}) { + const task = { task_id: 'task-a', confirmed_at: 'fixture', status: state, handoff_status: state, + result: { execution_id: 'execution-a', execution_phase: 'dispatch_uncertain', stage: 'dispatch', status: 'reconciliation_pending' } }; + const calls = []; + const timers = []; + const context = vm.createContext({ task, calls, Map, Set, Promise, Date, + taskServerStatus: t => t?.status, + findTaskById: () => task, + taskAssignedToCurrentAccount: () => owner, + extensionTaskBelongsToCurrentAccount: () => owner, + isAutomaticTask: () => true, + updateLocalTask: (_id, patch) => Object.assign(task, patch), + upsertLocalTask: t => Object.assign(task, t), + sessionStorage: { setItem() {} }, + apiRequest: async (path, options) => { + calls.push({ type: 'api', path, body: options.body }); + if (path.endsWith('/claim')) return { claimed: true, execution_id: 'execution-a', task: { ...task, status: 'queued' } }; + if (path.endsWith('/result')) { + if (resultError) throw new Error('fixture result rejected'); + const r = options.body.result; + const status = r.status === 'execution_uncertain' ? 'reconciliation_pending' : r.status; + return { task: { ...task, status, result: r, handoff_status: status } }; + } + throw new Error(`unexpected API ${path}`); + }, + sendToExtension: async (type, payload, timeout) => { + calls.push({ type, payload, timeout }); + if (type === 'GET_TASK_RESULT') return { result }; + if (type === 'CREATE_TASK') { + assert.ok(timeout >= 10_000); + if (acceptDelay > timeout) throw new Error('插件桥接未响应'); + return { task_id: 'task-a', execution_id: 'execution-a', accepted: true }; + } + throw new Error(`unexpected bridge action ${type}`); + }, + canReconcileTask: () => true, + pingBridge: async () => true, + renderTaskDetail: () => {}, startPolling: () => {}, syncRemoteTasks: async () => {}, + setTaskState: label => calls.push({ type: 'label', label }), + setTimeout: callback => timers.push(callback), clearTimeout: () => {}, + }); + const timeoutConstant = source.match(/const BRIDGE_TASK_ACCEPT_TIMEOUT_MS = [^;]+;/)?.[0]; + assert.ok(timeoutConstant, 'acceptance timeout must be explicit'); + vm.runInContext(`${timeoutConstant} + const MANUAL_RECONCILIATION_STATUSES = new Set(['reconciliation_pending', 'saved_unverified', 'execution_uncertain', 'uncertain']); + const TERMINAL_TASK_STATUSES = new Set(['completed', 'blocked', 'failed', 'cancelled', 'dry_run']); + const taskReconciliationStates = new Map(); + const browserConnectionId = 'connection-a'; + const REQUIRED_EXTENSION_VERSION = 'fixture'; + let currentTaskId = ''; + const bridgeConnected = true, extensionCompatible = true, erpHostAccessReady = true, erpSessionReady = true, erpAutomationEnabled = true; + const latestBridgeStatus = {}; + ${helperNames.map(functionSource).join('\n')} + queueExtensionTaskResult = persistExtensionTaskResult; + `, context); + return { task, calls, context, run: expr => vm.runInContext(expr, context) }; +} + +test('five-second plugin acceptance no longer triggers the old four-second false timeout', async () => { + const f = fixture({ state: 'confirmed', acceptDelay: 5000 }); + f.task.handoff_status = 'awaiting_handoff'; + await f.run('handoffTaskToExtension(task)'); + assert.equal(f.task.status, 'running'); + assert.equal(f.calls.filter(c => c.type === 'CREATE_TASK').length, 1); + assert.equal(f.calls.filter(c => c.path?.endsWith('/result')).length, 0); +}); + +test('a genuine acceptance timeout records uncertainty and never dispatches twice', async () => { + const f = fixture({ state: 'confirmed', acceptDelay: 15000 }); + f.task.handoff_status = 'awaiting_handoff'; + await assert.rejects(f.run('handoffTaskToExtension(task)'), /插件桥接未响应/); + assert.equal(f.task.status, 'reconciliation_pending'); + await assert.rejects(f.run('handoffTaskToExtension(task)'), /不会再次提交/); + assert.equal(f.calls.filter(c => c.type === 'CREATE_TASK').length, 1); +}); + +test('dispatch uncertainty reads stored late blocked results with no ERP action or new claim', async () => { + const f = fixture({ result: { execution_id: 'execution-a', status: 'blocked', stage: 'erp_resolution', no_erp_write: true } }); + assert.equal(f.run('isTaskPollable(task)'), true); + await f.run('pollTaskResult(task.task_id)'); + assert.equal(f.task.status, 'blocked'); + assert.deepEqual(f.calls.map(c => c.type), ['GET_TASK_RESULT', 'api']); + assert.equal(f.calls[1].body.execution_id, 'execution-a'); + assert.equal(f.calls[1].body.connection_id, 'connection-a'); +}); + +test('late progress does not replace dispatch uncertainty or post another API result', async () => { + for (const status of ['accepted', 'running', 'execution_uncertain']) { + const f = fixture({ result: { execution_id: 'execution-a', status } }); + await f.run('pollTaskResult(task.task_id)'); + assert.equal(f.task.status, 'reconciliation_pending'); + assert.equal(f.calls.length, 1); + } +}); + +test('a rejected stored result preserves the platform uncertainty state', async () => { + const f = fixture({ resultError: true, result: { execution_id: 'another', status: 'blocked', stage: 'erp_resolution' } }); + await f.run('pollTaskResult(task.task_id)'); + assert.equal(f.task.status, 'reconciliation_pending'); + assert.equal(f.task.result.execution_id, 'execution-a'); + assert.equal(f.task.result.execution_phase, 'dispatch_uncertain'); + assert.match(f.task.message, /持久化失败/); +}); + +test('post-write uncertainty keeps its existing manual reconciliation boundary', async () => { + const f = fixture(); f.task.result.execution_phase = 'submitted'; + assert.equal(f.run('isTaskPollable(task)'), false); + await f.run('pollTaskResult(task.task_id)'); + assert.equal(f.calls.length, 0); +}); + +test('maintenance-expired dispatch tasks can manually sync local plugin results without ERP reconciliation', async () => { + const f = fixture({ state: 'failed', result: { execution_id: 'execution-a', status: 'blocked', stage: 'erp_resolution', no_erp_write: true } }); + f.task.result.stage = 'reconciliation_timeout'; + assert.equal(f.run('isTaskPollable(task)'), false, 'expired tasks do not poll forever'); + await f.run('reconcileTaskReceipt(task)'); + assert.equal(f.task.status, 'blocked'); + assert.equal(f.calls.filter(c => c.type === 'GET_TASK_RESULT').length, 1); + assert.ok(!f.calls.some(c => c.type === 'RECONCILE_TASK' || c.type === 'CREATE_TASK' || c.path?.endsWith('/claim'))); +}); + +test('other-account tasks cannot read, persist or dispatch plugin tasks', async () => { + const f = fixture({ owner: false }); + await f.run('pollTaskResult(task.task_id)'); + await f.run("persistExtensionTaskResult(task.task_id, {status: 'blocked'})"); + await assert.rejects(f.run('reconcileTaskReceipt(task)'), /不能执行回查/); + assert.equal(f.calls.length, 0); +}); diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 6766a7d..153c210 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -18,6 +18,7 @@ const OPERATIONS_DASHBOARD_PAGE_SIZE = 20; const DEFAULT_API_TIMEOUT_MS = 15_000; const AUTH_API_TIMEOUT_MS = 10_000; const BRIDGE_PING_TIMEOUT_MS = 10_000; +const BRIDGE_TASK_ACCEPT_TIMEOUT_MS = 10_000; const RESULT_API_TIMEOUT_MS = 20_000; const TASK_DELETE_API_TIMEOUT_MS = 60_000; const LOCAL_TASK_OVERLAY_TTL_MS = 10 * 60_000; @@ -102,7 +103,7 @@ const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; -const REQUIRED_EXTENSION_VERSION = '0.5.183'; +const REQUIRED_EXTENSION_VERSION = '0.5.184'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; @@ -2667,7 +2668,14 @@ function renderTaskImportantMessage(task) { return panel; } +function isDispatchResultPending(task) { + return ['reconciliation_pending', 'failed'].includes(taskServerStatus(task)) + && task?.result?.execution_phase === 'dispatch_uncertain' + && ['dispatch', 'reconciliation_timeout'].includes(task.result.stage); +} + function isTaskPollable(task) { + if (isDispatchResultPending(task)) return taskServerStatus(task) === 'reconciliation_pending'; const localStatuses = [task?.status, task?.result?.status, task?.handoff_status] .map((value) => String(value || '').toLowerCase()); if (localStatuses.some((status) => MANUAL_RECONCILIATION_STATUSES.has(status))) return false; @@ -3801,7 +3809,7 @@ function renderTaskDetail() { confirmButton.textContent = reconciliationBusy ? '只读回查中…' : canReconcile - ? RECONCILE_LABEL + ? (isDispatchResultPending(task) ? '读取插件已保存结果' : RECONCILE_LABEL) : canResume ? '恢复保存前执行' : canStart @@ -4978,7 +4986,7 @@ async function handoffTaskToExtension(task) { const result = await sendToExtension('CREATE_TASK', { task: claim.task || task, execution_id: executionId - }, 4000); + }, BRIDGE_TASK_ACCEPT_TIMEOUT_MS); if (result?.accepted === false || result?.status === 'duplicate_blocked') { throw new Error(result.message || '插件检测到重复任务,已阻止再次执行。'); } @@ -5531,6 +5539,10 @@ function extensionResultVersion(result) { async function persistExtensionTaskResult(taskId, result) { if (!extensionTaskBelongsToCurrentAccount(taskId)) return false; + const dispatchPending = isDispatchResultPending(findTaskById(taskId)); + // Dispatch uncertainty only needs a stored terminal outcome. Ordinary + // progress must not turn a waiting task back into an executable task. + if (dispatchPending && !['blocked', 'completed'].includes(result.status)) return false; try { const executionId = result.execution_id; if (!executionId) { @@ -5548,9 +5560,7 @@ async function persistExtensionTaskResult(taskId, result) { return true; } catch (error) { updateLocalTask(taskId, { - result, - status: result.status || 'running', - stage: result.stage || '', + ...(dispatchPending ? {} : { result, status: result.status || 'running', stage: result.stage || '' }), message: `插件已返回结果,但服务端持久化失败:${error.message}`, error: error.message }); @@ -5595,7 +5605,7 @@ async function pollTaskResult(taskId, { allowReconciliation = false } = {}) { if (!allowReconciliation && knownTask) { const localStatuses = [knownTask.status, knownTask.result?.status, knownTask.handoff_status] .map((value) => String(value || '').toLowerCase()); - if (localStatuses.some((status) => MANUAL_RECONCILIATION_STATUSES.has(status))) return null; + if (localStatuses.some((status) => MANUAL_RECONCILIATION_STATUSES.has(status)) && !isDispatchResultPending(knownTask)) return null; } const response = await sendToExtension('GET_TASK_RESULT', { task_id: taskId }, 3000); if (response.result) await queueExtensionTaskResult(taskId, response.result); @@ -5610,6 +5620,14 @@ async function reconcileTaskReceipt(task) { taskReconciliationStates.set(task.task_id, 'running'); renderTaskDetail(); try { + if (isDispatchResultPending(task)) { + // The plugin may already be blocked before ERP resolution. Read its + // existing result instead of invoking the post-write reconciliation API. + const result = await pollTaskResult(task.task_id, { allowReconciliation: true }); + await syncRemoteTasks(); + setTaskState(isDispatchResultPending(findTaskById(task.task_id)) ? '尚未取得可确认的插件结果' : '插件结果已同步'); + return result; + } const result = await sendToExtension('RECONCILE_TASK', { task_id: task.task_id }, 60_000); await pollTaskResult(task.task_id, { allowReconciliation: true }); await syncRemoteTasks(); diff --git a/agent设计规范/business-adaptation-registry.md b/agent设计规范/business-adaptation-registry.md index b37768e..826bd57 100644 --- a/agent设计规范/business-adaptation-registry.md +++ b/agent设计规范/business-adaptation-registry.md @@ -30,6 +30,10 @@ 测试对象的 `order_delete` 回收能力仍保留在 Schema、插件和生命周期测试夹具中,但不属于生产运营业务,不登记为用户输入入口。 +## 列表检索与派发结果边界 + +`0.5.184` 起,原生业务列表检索在当前主 iframe 文档上验证页面加载完成、原生搜索函数与可用搜索按钮;旧文档被替换时重新取当前文档,检索中再次切换则停止。失败记录缺失条件与耗时,不能把通用检索失败等同于登录失效。派发回执超时后,只读取插件保存结果;同一任务、执行编号和原连接的明确只读检索未写入阻断可以同步关闭待回查状态,不重新执行。其他真实写入不确定状态保持原核验边界。本项本地回归与上线边界见[当前任务](../.project-docs/30-worklog/tasks/20260915-dispatch-readiness-01a0858b.md)。 + ## 新业务登记要求 新增业务时,先复制 [业务适配包模板](templates/business-adaptation-template.md) 创建一页式入口,再补本表一行。只有三项核心设计都完成,才可以进入 ERP 代码开发;只有真实回执逐条回查后,才可以标为“已真实验证”。 diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md index 66f9fe3..ae8863b 100644 --- a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md +++ b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md @@ -2,7 +2,7 @@ ## 当前基线 -- Chrome 插件:`0.5.183` +- Chrome 插件:`0.5.184` - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories` - 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08` - 五个 Skill:`0.5.126`;运营 DOCX:`0.5.133` @@ -117,3 +117,5 @@ - `0.5.182` 修复 `0.5.181` 日期匹配组件未注入 ERP MAIN world 的回归。上一版测试手动装载依赖,未覆盖真实脚本装载清单;现已改用生产注入列表的全新隔离页面测试,并在依赖缺失时单独报错。原安排唯一性、原日期、ID 和付款/审核门禁保持。服务端已为 `v1.0.12` 时只需升级插件并刷新 ERP 页面,无迁移;真实 ERP 验收待部署方执行。[修复任务](../../../.project-docs/30-worklog/tasks/20260914-arrangement-original-date-year-01a0858b.md)。 - `0.5.183` 修复安排联动检测在定时回调延迟时缺少末次采样的误报:延迟间隔不作为稳定证据,恢复后重新观察,持续变化/延迟及失效表单继续在保存前阻断;增加字段名与采样间隔诊断。用户本次失败的具体触发因素尚未有现场证据;本地回归与发布校验完成后记录于[修复任务](../../../.project-docs/30-worklog/tasks/20260914-arrangement-linkage-stability-01a0858b.md),真实 ERP 验收待部署方执行。 + +- `0.5.184` 对原生列表搜索按当前 iframe 文档等待完整加载、搜索函数和按钮就绪,并保留缺失条件与耗时诊断;平台将接单等待调整为 10 秒。仅派发超时且同任务、同执行编号、原连接返回明确只读检索未写入证据时,服务端接收迟到阻断结果。不会重新派发任务或放宽真实写入不确定状态。待回查派发任务读取插件已保存结果,历史超时任务可手动读取。需同步更新平台、服务端和插件,无数据库迁移;现场加载失败的具体缺失条件仍待新版诊断确认。见[任务记录](../../../.project-docs/30-worklog/tasks/20260915-dispatch-readiness-01a0858b.md)。 diff --git a/archive/evidence/2026-09-15/dispatch-readiness-01a0858b/README.md b/archive/evidence/2026-09-15/dispatch-readiness-01a0858b/README.md new file mode 100644 index 0000000..1708c1a --- /dev/null +++ b/archive/evidence/2026-09-15/dispatch-readiness-01a0858b/README.md @@ -0,0 +1,12 @@ +# Dispatch/readiness fix validation — 2026-09-15 + +Immutable local validation summary for [the feature task](../../../../.project-docs/30-worklog/tasks/20260915-dispatch-readiness-01a0858b.md). This is historical evidence, not the current release or business-rule authority. No private runtime input or customer records are stored here. + +- Base: `0282c30a2aa66435a193b96b17c0022420d881b3`; previous extension 0.5.183, new extension 0.5.184. +- Two actual-adapter tests reproduce same-path Document replacement failures on the committed old source and pass on the new source. Native search remains blocked for incomplete/wrong/inaccessible pages, absent loaders/buttons and changed documents; readiness failures carry explicit no-write page proof. +- 16 native readiness/worker tests, 8 platform dispatch/result tests and 8 control-plane recovery tests, including actual result-ingestion transaction logic with a synthetic database client. Five-second acceptance, timeout/no redispatch, missing or conflicting proof, original actor/connection/execution, duplicate idempotency and opposite arrival orders are covered. +- Full suites: 239 control-plane + 402 legacy = 641 passing tests. Typecheck/build PASS; isolated repository hygiene 10/10 PASS. Initial sandboxed legacy run could not bind loopback for two HTTP tests; permitted loopback rerun passed. Original worktree hygiene 8/10 reflects only pre-existing IDE/OS/historical-log residue, preserved unchanged. +- Extension ZIP has 20 source files and was verified byte-for-byte. Original ZIP 0.5.183 SHA-256: `95e838dbed02f7892eb1ee6c57c32f5fc363bf7887276475209d78c685867433`. +- Main-thread final review PASS; no subagents. No real ERP execution, deployment, restart, extension reload or task retry was performed. Specific historical page condition and acknowledgement-delay cause remain unknown; production validation is pending. + +Machine-readable verification: [summary.json](summary.json). diff --git a/archive/evidence/2026-09-15/dispatch-readiness-01a0858b/summary.json b/archive/evidence/2026-09-15/dispatch-readiness-01a0858b/summary.json new file mode 100644 index 0000000..a7dc6e3 --- /dev/null +++ b/archive/evidence/2026-09-15/dispatch-readiness-01a0858b/summary.json @@ -0,0 +1,35 @@ +{ + "base": "0282c30a2aa66435a193b96b17c0022420d881b3", + "extension": "0.5.184", + "artifact": "dist/ltjt-order-assistant-0.5.184.zip", + "sha256": "f8053808553c6fe1b75161225058447e8fbb13386b36a2048fd3599dfd7d120b", + "source_files": 20, + "zip_matches_source": true, + "previous_release_preserved": true, + "control_plane": { + "pass": 239, + "fail": 0 + }, + "legacy": { + "pass": 402, + "fail": 0 + }, + "check": "PASS", + "build": "PASS", + "isolated_repo": { + "pass": 10, + "fail": 0 + }, + "original_repo": { + "pass": 8, + "fail": 2, + "reason": "pre-existing IDE/OS files and ignored historical log residue" + }, + "review": { + "reviewer": "main thread", + "result": "PASS" + }, + "production_validation": "pending", + "erp_accessed": false, + "redispatched_task": false +} diff --git a/archive/evidence/2026-09-15/roster-header-aliases-01a0858b/README.md b/archive/evidence/2026-09-15/roster-header-aliases-01a0858b/README.md new file mode 100644 index 0000000..fde3224 --- /dev/null +++ b/archive/evidence/2026-09-15/roster-header-aliases-01a0858b/README.md @@ -0,0 +1,13 @@ +# Roster header alias verification — 2026-09-15 + +Immutable evidence for task `20260915-roster-header-aliases-01a0858b`; historical validation only, not the current business-rule source. No passenger values or workbook copies are retained here. + +- Original input: XLS, 48128 bytes, SHA-256 `c11d17fd13607afc9c8a52bf2716a5e900a3a387137f88ba314e8dcf15e3d76d`. +- Before: actual converter succeeds and existing normalizer returns `roster_workbook_header_not_found`; `Sheet1!H2` 出生地点 and `K2` 有效期至 were not mapped. +- After: normalizer v1.5.1 maps only these two new exact aliases; row 2 is the unique complete header. Rows 3–22 normalize to 20 canonical 13-column passenger rows. Source SHA-256 is unchanged after inspection. +- Content comparison: all 60 dates and all selected text/identifier/remark projections match the converted source; zero mismatches. Sequences 1–20 are unique. All required-source columns contain 20 populated values except phone (11 populated, nine intentionally retained blanks). Merged remarks follow their Excel master values; one leader marker has a name and phone. No imported-field formulas require adaptation. +- Independent and shared-child Program parsers both return `agent_parse_passed`, 20 rows and a leader contact using synthetic local-only lookup context. No database/task/ERP execution or external API call. +- Focused tests: 14/14. Full control-plane: 241/241. Full legacy: 402/402 after allowing its two local loopback HTTP tests. TypeScript/build PASS. Repository check: 10/10 in a 2699-file byte-verified source snapshot excluding secret files and unrelated existing residue. +- Synthetic regressions verify unchanged canonical output and rejection of both canonical-plus-alias and duplicate-alias columns. Source edits are restricted to the normalizer and its tests; task/evidence records document this result. No extension, template, schema or deployment changes. + +Acceptance scope: local file conversion, normalization and Program contract validation only. Actual ERP capacity, target selection and save results remain untested and were not requested. diff --git a/archive/releases/2026-09-15/README.md b/archive/releases/2026-09-15/README.md new file mode 100644 index 0000000..53f0d39 --- /dev/null +++ b/archive/releases/2026-09-15/README.md @@ -0,0 +1,5 @@ +# Releases archived on 2026-09-15 + +Historical material, not current business rules or release instructions. + +- [0.5.183 before dispatch/readiness fix](dispatch-readiness-01a0858b/README.md) diff --git a/archive/releases/2026-09-15/dispatch-readiness-01a0858b/README.md b/archive/releases/2026-09-15/dispatch-readiness-01a0858b/README.md new file mode 100644 index 0000000..9e8f4f4 --- /dev/null +++ b/archive/releases/2026-09-15/dispatch-readiness-01a0858b/README.md @@ -0,0 +1,3 @@ +# Historical release before dispatch/readiness fix + +This is a read-only historical copy, not the current release. Extension 0.5.183 and its original release manifest are preserved byte-for-byte. Current deliverables are defined by `dist/release-manifest.json`. diff --git a/archive/releases/2026-09-15/dispatch-readiness-01a0858b/ltjt-order-assistant-0.5.183.zip b/archive/releases/2026-09-15/dispatch-readiness-01a0858b/ltjt-order-assistant-0.5.183.zip new file mode 100644 index 0000000..01cae10 Binary files /dev/null and b/archive/releases/2026-09-15/dispatch-readiness-01a0858b/ltjt-order-assistant-0.5.183.zip differ diff --git a/archive/releases/2026-09-15/dispatch-readiness-01a0858b/release-manifest-0.5.183.json b/archive/releases/2026-09-15/dispatch-readiness-01a0858b/release-manifest-0.5.183.json new file mode 100644 index 0000000..7be3184 --- /dev/null +++ b/archive/releases/2026-09-15/dispatch-readiness-01a0858b/release-manifest-0.5.183.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 1, + "generated_on": "2026-09-14", + "baselines": { + "chrome_extension": "0.5.183", + "skills": "0.5.126", + "business_instruction_docx": "0.5.133", + "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" + }, + "artifacts": [ + { + "kind": "chrome_extension", + "version": "0.5.183", + "path": "dist/ltjt-order-assistant-0.5.183.zip", + "source": "chrome-extension/ltjt-order-assistant", + "sha256": "95e838dbed02f7892eb1ee6c57c32f5fc363bf7887276475209d78c685867433" + }, + { + "kind": "skill", + "name": "lwlt-arrangement", + "version": "0.5.126", + "path": "dist/lwlt-arrangement-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-arrangement", + "sha256": "83770befe3a8e4048eacaeacb567c5139bf8a0b3a49f2b1513c26383422f2a9c" + }, + { + "kind": "skill", + "name": "lwlt-confirmation", + "version": "0.5.126", + "path": "dist/lwlt-confirmation-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-confirmation", + "sha256": "543507ed8b68c183d2df6722061b0e927ebb728de4bf254d15f83311c9264018" + }, + { + "kind": "skill", + "name": "lwlt-lifecycle", + "version": "0.5.126", + "path": "dist/lwlt-lifecycle-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-lifecycle", + "sha256": "fff3879d9a4143c34ae51e091b91411e55382ad3f5d77c99d6202a005b1ddbd9" + }, + { + "kind": "skill", + "name": "lwlt-newbooking", + "version": "0.5.126", + "path": "dist/lwlt-newbooking-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-newbooking", + "sha256": "5eb6a7bf5649a0fcee3fbee488a124e1b1d018a642a3a49ebb5439ccf20ccc2f" + }, + { + "kind": "skill", + "name": "lwlt-updating", + "version": "0.5.126", + "path": "dist/lwlt-updating-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-updating", + "sha256": "3bd665207649cbb8e0e5ebb012c916720201fe160eea62bccdf1c3a1f0275d1d" + }, + { + "kind": "business_instruction_docx", + "version": "0.5.133", + "path": "dist/老挝联泰AI指令表-0.5.133.docx", + "source": "agent设计规范/templates/business-input-templates.md", + "builder": "tools/build_business_instruction_docx.py", + "sha256": "879f1acc3a6a60842e59ae8396f72d39504ea1f3a4110a9f305d94553b38848c", + "source_sha256": "37b6b689c5c6a80683dd1deeff52a90ac8b4bd3ac0380e6b771dcb557c0918f7", + "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" + } + ] +} diff --git a/chrome-extension/ltjt-order-assistant/README.md b/chrome-extension/ltjt-order-assistant/README.md index 18b226c..481410e 100644 --- a/chrome-extension/ltjt-order-assistant/README.md +++ b/chrome-extension/ltjt-order-assistant/README.md @@ -11,9 +11,13 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过 ## 当前版本 -当前源码版本为 `0.5.183`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 +当前源码版本为 `0.5.184`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 -0.5.183 当前重点: +0.5.184 当前重点: + +- ERP 列表就绪等待跟随当前 iframe 文档,仍要求加载完成、原生搜索函数与可用按钮,切换期间不在旧文档中搜索;失败回执明确区分页面未就绪,并记录不含业务值的条件与耗时。平台接单等待为 10 秒;明确未写入的迟到检索失败可在同执行编号、原连接范围内同步回平台。需同步更新平台/服务端与插件,无数据库迁移;不重新下发任务,真实写入不确定结果继续人工核验。 + +继续保留既有修复: - 安排联动稳定检查将正常采样观察预算与定时回调延迟分开;长间隔后重新观察至少 1.5 秒并要求多个有效样本,30 秒总等待上限内恢复,持续变化、持续延迟、控件失效或原生加载未结束均不保存。新增失败回执区分检测延迟与字段变化,详细证据仅记录字段名、采样次数和时间,不记录业务值。保留原生联动、唯一空行、付款审核、其他行保护及保存前复核;不自动重试保存。合成回归覆盖,真实 ERP 验收待部署方执行。 diff --git a/chrome-extension/ltjt-order-assistant/background.js b/chrome-extension/ltjt-order-assistant/background.js index 7e3b70f..367d874 100644 --- a/chrome-extension/ltjt-order-assistant/background.js +++ b/chrome-extension/ltjt-order-assistant/background.js @@ -2390,6 +2390,12 @@ function erpResolutionFailure(operation, report = {}) { } const navigationFailure = blockers.find((item) => item.startsWith('erp_resolution_navigation_failed:')); if (navigationFailure) { + if (/native_list_search_not_ready|native_list_search_document_changed/.test(navigationFailure)) { + return { + errorCode: 'erp_list_search_not_ready', + message: 'ERP 列表搜索页面未就绪或在检索时发生切换,已停止,未写入 ERP。请待页面加载完成后再操作。' + }; + } return { errorCode: 'erp_target_lookup_failed', message: `ERP 检索${targetLabel}失败,请确认 ERP 已登录后重试。` diff --git a/chrome-extension/ltjt-order-assistant/inpage.js b/chrome-extension/ltjt-order-assistant/inpage.js index 0ed85bf..0b31d7b 100644 --- a/chrome-extension/ltjt-order-assistant/inpage.js +++ b/chrome-extension/ltjt-order-assistant/inpage.js @@ -1402,19 +1402,72 @@ }); } + async function waitForNativeListSearch(initialDocument, timeoutMs = 15000) { + const expectedPath = String(initialDocument?.location?.pathname || '').toLowerCase(); + const framed = initialDocument !== document || Boolean(mainIframe()); + const now = () => typeof performance !== 'undefined' ? performance.now() : Date.now(); + const startedAt = now(); + let samples = 0; + let replacements = 0; + let previousDocument = initialDocument; + let readiness; + while (true) { + // A navigating iframe can expose the destination URL before replacing + // its Document. Never continue waiting on that detached old Document. + let current = null; + let accessible = true; + try { current = framed ? mainIframe()?.contentDocument : document; } + catch { accessible = false; } + if (current && current !== previousDocument) replacements += 1; + previousDocument = current; + let pathMatched = false; + let complete = false; + let loaderReady = false; + let buttonReady = false; + try { + pathMatched = Boolean(current && expectedPath && String(current.location.pathname).toLowerCase() === expectedPath); + complete = current?.readyState === 'complete'; + loaderReady = typeof current?.defaultView?.AjaxLoadData === 'function'; + const button = current?.querySelector('#SearchButton, [name="SearchButton"]'); + buttonReady = Boolean(button && button.isConnected !== false && button.disabled !== true); + } catch { accessible = false; } + samples += 1; + readiness = { + document_accessible: accessible, document_present: Boolean(current), + path_matched: pathMatched, document_complete: complete, + loader_ready: loaderReady, search_button_ready: buttonReady, + document_replacements: replacements, samples, + elapsed_ms: Math.round(now() - startedAt) + }; + if (accessible && pathMatched && complete && loaderReady && buttonReady) { + return { document: current, readiness }; + } + if (readiness.elapsed_ms >= timeoutMs) { + const missing = ['document_accessible', 'document_present', 'path_matched', 'document_complete', 'loader_ready', 'search_button_ready'] + .filter((key) => !readiness[key]); + const error = new Error(`native_list_search_not_ready:${missing.join(',')}:elapsed_ms=${readiness.elapsed_ms}:document_replacements=${replacements}`); + error.nativeListReadiness = readiness; + throw error; + } + await sleep(100); + } + } + + function assertNativeListDocumentCurrent(scopeDocument) { + const current = mainIframe() ? mainIframeDocument() : document; + if (current !== scopeDocument) throw new Error('native_list_search_document_changed'); + } + async function searchMainPage(scopeDocument, values = {}) { - const nativeSearchReady = await waitForCondition(() => ( - scopeDocument.readyState === 'complete' - && typeof scopeDocument.defaultView?.AjaxLoadData === 'function' - && Boolean(scopeDocument.querySelector('#SearchButton, [name="SearchButton"]')) - ), 15000, 100); - if (!nativeSearchReady) throw new Error('native_list_search_not_ready'); + const ready = await waitForNativeListSearch(scopeDocument); + scopeDocument = ready.document; const searchResolution = resolveNativeListSearchValues(values); const searchValues = searchResolution.values; const jq = scopeDocument.defaultView?.jQuery || scopeDocument.defaultView?.$; if (typeof jq !== 'function' || typeof jq.ajax !== 'function') throw new Error('native_list_search_ajax_not_ready'); const priorAjaxIdle = await waitForCondition(() => Number(jq.active || 0) === 0, 12000, 50); if (!priorAjaxIdle) throw new Error('native_list_search_prior_ajax_not_idle'); + assertNativeListDocumentCurrent(scopeDocument); const setIfPresent = (name, value) => { const element = scopeDocument.querySelector(`#${name}, [name="${name}"]`); if (!element) return false; @@ -1486,6 +1539,7 @@ ), 3000, 50); if (!statusApplied) throw new Error('native_status_filter_not_applied'); const clickRequestStarted = await waitForCondition(() => capturedXhr, 3000, 25); + assertNativeListDocumentCurrent(scopeDocument); if (!clickRequestStarted) scopeDocument.defaultView.AjaxLoadData(true); } else { // Invoke the ERP's own list loader directly. The exact ajaxSend/ajaxComplete @@ -1498,10 +1552,12 @@ const completed = await waitForCondition(() => requestCompleted, 15000, 50); if (!completed) throw new Error('native_list_search_result_timeout'); if (httpStatus < 200 || httpStatus >= 300) throw new Error(`native_list_search_http_${httpStatus || 'error'}`); + assertNativeListDocumentCurrent(scopeDocument); } finally { eventTarget.off(eventNamespace); } scopeDocument.__ltjtNativeListSearchEvidence = { + readiness: ready.readiness, date_strategy: searchResolution.date_strategy, fallback_applied: searchResolution.fallback_applied, date_range: searchResolution.date_range, @@ -1819,7 +1875,7 @@ async function searchLifecycleList(path, pattern, criteria, listKind, statusOverride = '') { const searchValues = lifecycleSearchValues(criteria, listKind, statusOverride); let scopeDocument = await ensureMainPage(path, pattern, searchValues); - await searchMainPage(scopeDocument, searchValues); + scopeDocument = await searchMainPage(scopeDocument, searchValues); let stable = await waitForCondition(() => { const current = currentMainListDocument(pattern, scopeDocument); return current?.readyState === 'complete' ? current : null; @@ -1839,7 +1895,7 @@ if (independentBusinessLookup && (!visibleRows.length || !customerEvidence)) { const primaryEvidence = stable?.__ltjtNativeListSearchEvidence || null; const fallbackValues = { ...searchValues, S_kehuming: '' }; - await searchMainPage(scopeDocument, fallbackValues); + scopeDocument = await searchMainPage(scopeDocument, fallbackValues); stable = await waitForCondition(() => { const current = currentMainListDocument(pattern, scopeDocument); return current?.readyState === 'complete' ? current : null; @@ -2463,6 +2519,7 @@ blockers: [`erp_resolution_navigation_failed:${error?.message || String(error)}`], preflight: { stage: 'erp_readonly_resolution', + ...(error.nativeListReadiness ? { native_list_readiness: error.nativeListReadiness } : {}), lookup_mode: criteria.mode, identifier, supplied_filters: { @@ -2552,7 +2609,7 @@ let listDocument; try { listDocument = await ensureMainPage(listPath, listPattern, searchValues); - await searchMainPage(listDocument, searchValues); + listDocument = await searchMainPage(listDocument, searchValues); } catch (error) { return lifecycleRouteOutcome('lifecycle_route_blocked', operation, { blockers: [`passenger_list_navigation_failed:${error?.message || String(error)}`] @@ -2816,7 +2873,7 @@ let listDocument; try { listDocument = await ensureMainPage(listPath, listPattern, searchValues); - await searchMainPage(listDocument, searchValues); + listDocument = await searchMainPage(listDocument, searchValues); } catch (error) { return { ok: false, @@ -3893,7 +3950,7 @@ S_lingdui: '', S_zhuangtai: '' }; - await searchMainPage(scopeDocument, searchValues); + scopeDocument = await searchMainPage(scopeDocument, searchValues); const row = rowForIdentifier(scopeDocument, groupNumber); return { found: Boolean(row), @@ -3939,7 +3996,7 @@ S_lingdui: '', S_zhuangtai: '' }; - await searchMainPage(scopeDocument, searchValues); + scopeDocument = await searchMainPage(scopeDocument, searchValues); const selectionExpected = options.fact_probe ? { ...expected, customer_name: '', passenger_counts: undefined, require_customer_match: false, date } : { ...expected, date }; @@ -4584,7 +4641,7 @@ S_tuanxuhao: parentGroup, S_zhuangtai: '' }); - await searchMainPage(listDocument, { + listDocument = await searchMainPage(listDocument, { S_chufariqi: dateYyyyMD(dates[0] || ''), S_chufarizhi: dateYyyyMD(dates[0] || ''), S_tuanxuhao: parentGroup, @@ -4945,7 +5002,7 @@ S_tuanxuhao: listIdentifier }; let listDocument = await ensureMainPage(listPath, listPattern, searchValues); - await searchMainPage(listDocument, searchValues); + listDocument = await searchMainPage(listDocument, searchValues); const sourceSearchEvidence = listDocument.__ltjtNativeListSearchEvidence || null; const stableSourceDocument = await waitForCondition(() => { const current = currentMainListDocument(listPattern, listDocument); @@ -6543,7 +6600,7 @@ } window.LTJTOrderAssistant = { - version: '0.5.183', + version: '0.5.184', resolveNativeListSearchValues, lookupKeywordMatchesText, inspectLifecycleSearchCriteria: lifecycleSearchCriteria, diff --git a/chrome-extension/ltjt-order-assistant/manifest.json b/chrome-extension/ltjt-order-assistant/manifest.json index 9510188..6cb69ff 100644 --- a/chrome-extension/ltjt-order-assistant/manifest.json +++ b/chrome-extension/ltjt-order-assistant/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "联泰下单助手", - "version": "0.5.183", + "version": "0.5.184", "description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。", "permissions": [ "activeTab", diff --git a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js index 574d423..43c07a0 100644 --- a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js +++ b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js @@ -944,7 +944,7 @@ window.LTJTOrderAssistant = { ...(window.LTJTOrderAssistant || {}), - version: '0.5.183', + version: '0.5.184', openTeamBatchForm, pingTeamBatchFrame, preflightTeamBatchNative, diff --git a/control-plane/src/passenger-roster-workbook.ts b/control-plane/src/passenger-roster-workbook.ts index 08f4f05..aa15898 100644 --- a/control-plane/src/passenger-roster-workbook.ts +++ b/control-plane/src/passenger-roster-workbook.ts @@ -13,7 +13,7 @@ const ABSOLUTE_MAX_DATA_ROWS = 5_000; const MAX_PASSENGER_SEQUENCE = 5_000; const MAX_HEADER_ROW = 100; -export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.5.0'; +export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.5.1'; const REQUIRED_SOURCE_FIELDS = [ '序号', '姓名', '英文姓名', '性别', '出生日期', '出生地', '护照号码', @@ -36,12 +36,14 @@ const HEADER_FIELD_ENTRIES: ReadonlyArray { no_erp_write: result.no_erp_write === true, write_attempted: result.write_attempted === true, execution_id: result.execution_id || '', + ...(result.execution_phase === 'dispatch_uncertain' ? { + execution_phase: 'dispatch_uncertain', + reconciliation_required: result.reconciliation_required === true, + reconciliation_stale: result.reconciliation_stale === true + } : {}), ...(operationTiming ? { operation_timing: operationTiming } : {}) }; } @@ -2351,6 +2356,58 @@ export function hasPrewriteNoErpEvidence(value: unknown): boolean { && ajaxRecords.every((record) => record.prevented_from_network === true || record.network_request_attempted === false); } +/** Only a dispatch acknowledgement timeout may be closed by a late, proven + * read-only lookup failure. This is result ingestion, never permission to run + * the task again. The caller still enforces actor, task and execution access. */ +export function canResolveDispatchNoWrite(input: { + taskStatus: string; attemptStatus: string; previous: unknown; incoming: unknown; + executionId: string; connectionId: string; claimedConnectionId: string; +}): boolean { + const previous = jsonObject(input.previous); + const result = jsonObject(input.incoming); + const report = jsonObject(result.report); + const preflight = jsonObject(report.preflight); + const timedOut = input.taskStatus === 'failed' + && previous.reconciliation_required === true && previous.reconciliation_stale === true; + const sameExecution = Boolean(input.executionId) + && text(previous.execution_id) === input.executionId && text(result.execution_id) === input.executionId; + const sameConnection = Boolean(input.connectionId) + && input.connectionId === input.claimedConnectionId; + return (input.taskStatus === 'reconciliation_pending' || timedOut) + && input.attemptStatus === 'reconciliation_pending' && sameExecution && sameConnection + && text(previous.execution_phase) === 'dispatch_uncertain' + && ['dispatch', 'reconciliation_timeout'].includes(text(previous.stage)) + && !previous.report && !previous.native_request && !previous.server_response + && !previous.erp_receipt && !previous.success_receipt && !hasCompletionEvidence(previous) + && text(result.status) === 'blocked' && text(result.stage) === 'erp_resolution' + && text(result.failure_source) === 'plugin_executor' + && result.no_erp_write === true && result.write_attempted === false && result.erp_write_started !== true + && !/submit|write_started|erp_write/i.test(text(result.execution_phase)) + && text(report.status) === 'erp_resolution_blocked' + && text(preflight.stage) === 'erp_readonly_resolution' + && report.no_erp_write === true && report.write_attempted === false + && preflight.no_erp_write === true && preflight.write_attempted === false + && report.erp_write_started !== true && preflight.erp_write_started !== true + && jsonObject(report.submit_safety).live_submit_attempted !== true + && !report.native_request && !report.server_response && !report.requery + && !result.native_request && !result.server_response && !result.requery + && !result.erp_receipt && !result.success_receipt && !report.erp_receipt && !report.success_receipt + && !hasCompletionEvidence(result) + && Array.isArray(report.blockers) && report.blockers.length > 0 + && Array.isArray(report.side_effects) + && report.side_effects.every((item) => ['no_procurement', 'no_payment', 'no_notification', 'no_external_send'].includes(text(item))) + && (report.ajax_records == null || (Array.isArray(report.ajax_records) && report.ajax_records.length === 0)); +} + +export function shouldIgnoreDispatchTimeout(previousValue: unknown, incomingValue: unknown): boolean { + const previous = jsonObject(previousValue); + const incoming = jsonObject(incomingValue); + return text(incoming.execution_phase) === 'dispatch_uncertain' && text(incoming.stage) === 'dispatch' + && Boolean(text(incoming.execution_id)) && text(previous.execution_id) === text(incoming.execution_id) + && Boolean(text(previous.stage)) && text(previous.stage) !== 'dispatch' + && ['accepted', 'running', 'completed', 'blocked', 'failed', 'cancelled', 'dry_run', 'reconciliation_pending'].includes(text(previous.status)); +} + function publicShadowDifference(row: Record, config: AppConfig): PublicParserShadowDifference { const programCandidate = decryptedJson(config, row.program_result_ciphertext); const aiCandidate = decryptedJson(config, row.ai_result_ciphertext); @@ -6505,6 +6562,10 @@ export class TaskService { const previousExecutionResult = jsonObject( decryptedJson(this.config, row.execution_result_ciphertext) || row.execution_result ); + if (text(jsonObject(attemptRow.details).connection_id) === connectionId + && shouldIgnoreDispatchTimeout(previousExecutionResult, preparedResult)) { + return { row, event: null }; + } let resultObject = preparedResult; if (prepared.required && !prepared.failureCode) { const stored: StoredTaskArtifact[] = []; @@ -6536,6 +6597,23 @@ export class TaskService { const stage = text(resultObject.stage || 'erp').slice(0, 160); if (rawStatus !== status) resultObject.result_status = rawStatus; resultObject.status = status; + const attemptDetails = jsonObject(attemptRow.details); + const canFinalizeDispatchNoWrite = canResolveDispatchNoWrite({ + taskStatus: text(row.status), attemptStatus: text(attemptRow.status), + previous: previousExecutionResult, incoming: resultObject, executionId, + connectionId, claimedConnectionId: text(attemptDetails.connection_id) + }); + const repeatsResolvedDispatchResult = text(row.status) === 'blocked' + && previousExecutionResult.reconciliation_resolution === 'late_dispatch_readonly_failure' + && text(previousExecutionResult.execution_id) === executionId + && text(attemptDetails.connection_id) === connectionId; + // Restore server-derived metadata for an exact duplicate's hash. Any + // changed result still fails the immutable-state check below. + if (canFinalizeDispatchNoWrite || repeatsResolvedDispatchResult) { + resultObject.reconciliation_resolved = true; + resultObject.reconciliation_required = false; + resultObject.reconciliation_resolution = 'late_dispatch_readonly_failure'; + } const lifecycle = executionLifecycleFacts(resultObject, status, true); Object.assign(resultObject, lifecycle); const message = text(resultObject.message || (uncertain ? 'ERP 结果不确定,请勿重复提交同一任务,等待任务所属账号只读核验。' : '插件已返回状态。')).slice(0, 2_000); @@ -6550,7 +6628,6 @@ export class TaskService { if (text(attemptRow.response_hash) === resultHash) { return { row, event: null }; } - const attemptDetails = jsonObject(attemptRow.details); const sameConnection = !text(attemptDetails.connection_id) || text(attemptDetails.connection_id) === connectionId; const failedReconciliation = text(row.status) === 'failed' && previousExecutionResult.reconciliation_required === true; @@ -6569,7 +6646,7 @@ export class TaskService { && hasPrewriteNoErpEvidence(previousExecutionResult) && hasPrewriteNoErpEvidence(resultObject) && sameConnection; - const canFinalizeTerminalReconciliation = canFinalizeReconciliation || canFinalizeNoWriteReconciliation; + const canFinalizeTerminalReconciliation = canFinalizeReconciliation || canFinalizeNoWriteReconciliation || canFinalizeDispatchNoWrite; if (executionStatusImmutable(text(row.status)) && !canFinalizeTerminalReconciliation) { throw new TaskError('result_after_terminal', `任务已处于不可变状态 ${row.status},迟到结果已忽略。`); } @@ -6652,7 +6729,7 @@ export class TaskService { execution_phase: text(resultObject.execution_phase) } }, context.userId); - await this.audit(client, context, canFinalizeNoWriteReconciliation + await this.audit(client, context, canFinalizeNoWriteReconciliation || canFinalizeDispatchNoWrite ? 'task.reconciliation_no_write_resolved' : canFinalizeReconciliation ? 'task.reconciliation_resolved' @@ -6662,7 +6739,7 @@ export class TaskService { connection_id: connectionId, terminal, reconciliation_resolved: canFinalizeTerminalReconciliation, - no_erp_write_resolution: canFinalizeNoWriteReconciliation + no_erp_write_resolution: canFinalizeNoWriteReconciliation || canFinalizeDispatchNoWrite }); return { row: updatedRow, event }; }); diff --git a/control-plane/test/dispatch-result-recovery.test.ts b/control-plane/test/dispatch-result-recovery.test.ts new file mode 100644 index 0000000..7133ae7 --- /dev/null +++ b/control-plane/test/dispatch-result-recovery.test.ts @@ -0,0 +1,189 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { TaskService, canResolveDispatchNoWrite, shouldIgnoreDispatchTimeout } from '../src/task-service.js'; +import { loadConfig } from '../src/config.js'; +import { getPool, closePool } from '../src/db.js'; +import { decryptText } from '../src/crypto.js'; + +function fixture() { + return { + taskStatus: 'reconciliation_pending', attemptStatus: 'reconciliation_pending', + executionId: 'execution-a', connectionId: 'connection-a', claimedConnectionId: 'connection-a', + previous: { + execution_id: 'execution-a', stage: 'dispatch', execution_phase: 'dispatch_uncertain', + status: 'reconciliation_pending', write_attempted: true, erp_write_started: true, no_erp_write: false + } as Record, + incoming: { + execution_id: 'execution-a', status: 'blocked', stage: 'erp_resolution', failure_source: 'plugin_executor', + no_erp_write: true, write_attempted: false, + report: { + status: 'erp_resolution_blocked', no_erp_write: true, write_attempted: false, + preflight: { stage: 'erp_readonly_resolution', no_erp_write: true, write_attempted: false }, + native_request: null, server_response: null, requery: null, + blockers: ['erp_resolution_navigation_failed:native_list_search_not_ready'], + side_effects: ['no_procurement', 'no_payment', 'no_notification', 'no_external_send'] + } as Record + } as Record + }; +} + +test('same execution read-only failure resolves provisional dispatch uncertainty', () => { + assert.equal(canResolveDispatchNoWrite(fixture()), true); +}); + +test('maintenance-expired dispatch uncertainty still accepts the original explicit no-write result', () => { + const f = fixture(); f.taskStatus = 'failed'; + Object.assign(f.previous, { stage: 'reconciliation_timeout', reconciliation_required: true, reconciliation_stale: true }); + assert.equal(canResolveDispatchNoWrite(f), true); + delete f.previous.reconciliation_stale; + assert.equal(canResolveDispatchNoWrite(f), false); +}); + +test('late no-write resolution enforces execution, original connection and immutable state boundaries', () => { + for (const [key, values] of Object.entries({ + taskStatus: ['completed', 'cancelled', 'blocked', 'running', 'failed'], + attemptStatus: ['completed', 'cancelled', 'blocked', 'running', 'failed'], + executionId: ['', 'another'], connectionId: ['', 'another'], claimedConnectionId: ['', 'another'] + })) for (const value of values) { + assert.equal(canResolveDispatchNoWrite({ ...fixture(), [key]: value }), false, `${key}=${value}`); + } + for (const key of ['incoming', 'previous'] as const) { + const f = fixture(); f[key].execution_id = 'another'; + assert.equal(canResolveDispatchNoWrite(f), false, key); + } +}); + +test('true post-write uncertainty and prior native evidence cannot be overwritten', () => { + for (const patch of [ + { execution_phase: 'write_started' }, { execution_phase: '' }, { stage: 'lifecycle_live' }, + { report: { native_request: { url: 'fixture' } } }, { native_request: {} }, { server_response: {} }, + { erp_receipt: { group_numbers: ['TEST-001'] } } + ]) { + const f = fixture(); Object.assign(f.previous, patch); + assert.equal(canResolveDispatchNoWrite(f), false, JSON.stringify(patch)); + } +}); + +test('bare no-write flags, contradictory write evidence and non-lookup results are rejected', () => { + for (const patch of [ + { report: null }, { status: 'running' }, { status: 'failed' }, { stage: 'lifecycle_live' }, + { failure_source: 'program_parser' }, { no_erp_write: false }, { write_attempted: true }, + { write_attempted: undefined }, { erp_write_started: true }, { execution_phase: 'submit_started' }, + { native_request: {} }, { server_response: {} }, { requery: {} }, { erp_receipt: { group_numbers: ['TEST-001'] } } + ]) { + const f = fixture(); Object.assign(f.incoming, patch); + assert.equal(canResolveDispatchNoWrite(f), false, JSON.stringify(patch)); + } +}); + +test('page-level no-write evidence must be complete and internally consistent', () => { + for (const patch of [ + { status: 'live_submit_blocked' }, { no_erp_write: false }, { write_attempted: true }, + { native_request: {} }, { server_response: {} }, { requery: {} }, + { blockers: [] }, { blockers: null }, { side_effects: ['payment'] }, { side_effects: {} }, + { ajax_records: {} }, { ajax_records: [{ network_request_attempted: true }] }, + { preflight: {} }, { preflight: { stage: 'erp_readonly_resolution', no_erp_write: true, write_attempted: true } } + ]) { + const f = fixture(); Object.assign(f.incoming.report as object, patch); + assert.equal(canResolveDispatchNoWrite(f), false, JSON.stringify(patch)); + } +}); + +test('provisional timeout cannot replace already-received plugin progress or a final outcome', () => { + const incoming = fixture().previous; + for (const status of ['accepted', 'running', 'completed', 'blocked', 'failed', 'cancelled', 'dry_run', 'reconciliation_pending']) { + assert.equal(shouldIgnoreDispatchTimeout({ execution_id: 'execution-a', stage: 'erp_resolution', status }, incoming), true, status); + } + for (const previous of [{}, { execution_id: 'another', stage: 'erp_resolution', status: 'running' }, + { execution_id: 'execution-a', stage: 'dispatch', status: 'reconciliation_pending' }]) { + assert.equal(shouldIgnoreDispatchTimeout(previous, incoming), false); + } + assert.equal(shouldIgnoreDispatchTimeout({ execution_id: 'execution-a', stage: 'erp_resolution', status: 'running' }, { ...incoming, execution_phase: 'write_started' }), false); +}); + +test('real result-ingestion transaction closes only eligible dispatch failures and emits consistent facts', async t => { + const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 27).toString('base64'), + DATABASE_URL: 'postgresql://fixture:fixture@127.0.0.1:1/fixture' }); + const pool = getPool(config); + t.after(async () => { t.mock.restoreAll(); await closePool(); }); + let row: Record; + let attempt: Record; + let events: Array>; + let queries: string[]; + const client = { release() {}, async query(sql: string, params: unknown[] = []) { + queries.push(sql.trim().split(/\s+/).slice(0, 2).join(' ')); + if (/SELECT \* FROM task_attempts/.test(sql)) return { rowCount: params[0] === 'execution-a' ? 1 : 0, rows: [attempt] }; + if (/UPDATE tasks\s/.test(sql)) { + row.execution_result_ciphertext = params[0]; row.execution_result = params[1]; + row.status = params[2]; row.stage = params[3]; row.message = params[4]; + if (params[8]) row.lease_owner = null; + return { rowCount: 1, rows: [row] }; + } + if (/UPDATE task_attempts/.test(sql)) { + attempt.status = params[0]; attempt.response_hash = params[1]; + return { rowCount: 1, rows: [] }; + } + if (!['BEGIN', 'COMMIT', 'ROLLBACK'].includes(sql)) throw new Error(`unexpected SQL ${sql}`); + return { rowCount: 0, rows: [] }; + } }; + t.mock.method(pool, 'connect', async () => client); + const service = new TaskService(config); + Object.assign(service, { + lockTaskForAccess: async () => row, + requireBrowserConnection: async () => {}, + getTask: async () => row, + emitEvent: async (_client: unknown, _row: unknown, event: Record) => { events.push(event); return event; }, + audit: async () => {}, notify: () => {} + }); + const context = { organizationId: 'org-a', userId: 'user-a', role: 'user' as const, requestId: 'fixture' }; + for (const scenario of ['late', 'expired', 'wrong_connection', 'wrong_execution', 'wrong_actor', 'completed', 'postwrite', 'bare_flags', 'progress_first']) { + const f = fixture(); + if (scenario === 'expired') { + f.taskStatus = 'failed'; + Object.assign(f.previous, { stage: 'reconciliation_timeout', reconciliation_required: true, reconciliation_stale: true }); + } + if (scenario === 'postwrite') Object.assign(f.previous, { stage: 'lifecycle_live', execution_phase: 'submitted' }); + if (scenario === 'bare_flags') delete f.incoming.report; + if (scenario === 'progress_first') { + f.taskStatus = 'running'; + Object.assign(f.previous, { stage: 'erp_resolution', status: 'running', execution_phase: '' }); + } + row = { id: 'row-a', task_id: 'task-a', assigned_user_id: 'user-a', organization_id: 'org-a', + status: scenario === 'completed' ? 'completed' : f.taskStatus, execution_result: f.previous, + lease_owner: scenario === 'progress_first' ? 'browser:connection-a' : null }; + attempt = { id: 'execution-a', status: f.attemptStatus, details: { connection_id: 'connection-a' } }; + events = []; queries = []; + const operation = service.recordExecutionResult( + scenario === 'wrong_actor' ? { ...context, userId: 'user-b' } : context, + 'task-a', scenario === 'progress_first' ? fixture().previous : f.incoming, + scenario === 'wrong_connection' ? 'connection-b' : 'connection-a', + scenario === 'wrong_execution' ? 'execution-b' : 'execution-a', { skipArtifactPersistence: true } + ); + if (['late', 'expired'].includes(scenario)) { + await operation; + const stored = JSON.parse(decryptText(config, String(row.execution_result_ciphertext))); + assert.equal(row.status, 'blocked', scenario); + assert.equal(attempt.status, 'blocked'); + assert.equal(stored.reconciliation_resolved, true); + assert.equal(stored.no_erp_write, true); + assert.equal(stored.erp_write_started, false); + assert.equal(stored.write_attempted, false); + assert.equal(events.length, 1); + assert.equal((events[0].payload as Record).reconciliation_resolved, true); + assert.ok(queries.includes('COMMIT')); + await service.recordExecutionResult(context, 'task-a', structuredClone(fixture().incoming), 'connection-a', 'execution-a', { skipArtifactPersistence: true }); + assert.equal(events.length, 1, 'a duplicate read must not emit another terminal event'); + await assert.rejects(service.recordExecutionResult(context, 'task-a', { ...fixture().incoming, message: 'changed' }, 'connection-a', 'execution-a', { skipArtifactPersistence: true }), /不可变状态/); + } else if (scenario === 'progress_first') { + await operation; + assert.equal(row.status, 'running'); + assert.equal(events.length, 0); + assert.ok(!queries.includes('UPDATE tasks')); + } else { + await assert.rejects(operation, Error, scenario); + assert.equal(events.length, 0); + assert.ok(!queries.includes('UPDATE tasks'), scenario); + assert.ok(queries.includes('ROLLBACK') || scenario === 'wrong_execution'); + } + } +}); diff --git a/control-plane/test/passenger-roster-workbook.test.ts b/control-plane/test/passenger-roster-workbook.test.ts index 339d946..e6551ff 100644 --- a/control-plane/test/passenger-roster-workbook.test.ts +++ b/control-plane/test/passenger-roster-workbook.test.ts @@ -246,6 +246,39 @@ test('the approved attachment aliases and reverse issue-date formula are normali ].join('\n')); }); +test('birthplace and expiry aliases preserve the canonical passenger fields and values', async () => { + const normalize = (content: Buffer) => normalizePassengerRosterWorkbook({ + content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' + }); + const original = await normalize(await syntheticWorkbook()); + const aliased = await normalize(await syntheticWorkbook(workbook => { + const sheet = workbook.getWorksheet('Sheet1')!; + sheet.getCell('H2').value = '出生地点'; + sheet.getCell('L2').value = '有效期至'; + })); + assert.equal(aliased.headerRow, original.headerRow); + assert.equal(aliased.rowCount, original.rowCount); + assert.equal(aliased.canonicalTsv, original.canonicalTsv); +}); + +test('birthplace and expiry aliases cannot silently select duplicate semantic columns', async () => { + for (const [column, alias] of [['H2', '出生地点'], ['L2', '有效期至']] as const) { + for (const duplicate of ['canonical', 'alias'] as const) { + const content = await syntheticWorkbook(workbook => { + const sheet = workbook.getWorksheet('Sheet1')!; + const original = sheet.getCell(column).value; + sheet.getCell(column).value = alias; + sheet.getCell('O2').value = duplicate === 'canonical' ? original : alias; + sheet.getCell('O3').value = '合成冲突值'; + }); + await expectRosterError( + () => normalizePassengerRosterWorkbook({ content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), + 'roster_workbook_header_not_found' + ); + } + } +}); + test('source header order may vary, but every required label must appear exactly once', async () => { const missingHeader = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('N2').value = '备注说明'; diff --git a/dist/release-manifest.json b/dist/release-manifest.json index 7be3184..8d888ea 100644 --- a/dist/release-manifest.json +++ b/dist/release-manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 1, - "generated_on": "2026-09-14", + "generated_on": "2026-09-15", "baselines": { - "chrome_extension": "0.5.183", + "chrome_extension": "0.5.184", "skills": "0.5.126", "business_instruction_docx": "0.5.133", "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" @@ -10,10 +10,10 @@ "artifacts": [ { "kind": "chrome_extension", - "version": "0.5.183", - "path": "dist/ltjt-order-assistant-0.5.183.zip", + "version": "0.5.184", + "path": "dist/ltjt-order-assistant-0.5.184.zip", "source": "chrome-extension/ltjt-order-assistant", - "sha256": "95e838dbed02f7892eb1ee6c57c32f5fc363bf7887276475209d78c685867433" + "sha256": "f8053808553c6fe1b75161225058447e8fbb13386b36a2048fd3599dfd7d120b" }, { "kind": "skill", diff --git a/mappings/lifecycle.mapping.json b/mappings/lifecycle.mapping.json index d3af4aa..8461a99 100644 --- a/mappings/lifecycle.mapping.json +++ b/mappings/lifecycle.mapping.json @@ -1,7 +1,7 @@ { "contract_version": "ltjt-lifecycle-v2.9-roster-leader-contact-2026-08", "current_agent_parse_prompt_version": "ltjt-agent-prompt-v1.8-independent-headcount-categories", - "current_extension_version": "0.5.183", + "current_extension_version": "0.5.184", "original_arrangement_date_selection": "Hotel, vehicle, transport and other/filing update selection accepts MM-DD or YYYY-MM-DD; omitted year matches original ERP month/day within the exact group only. Require exactly one persisted row, then freeze its actual full dates and id. Full year is exact; mixed/cross-year endpoints are matched independently with actual range order checked. No match or ambiguity blocks before mutation; show up to five original candidates and ask to resubmit complete dates. Creation and legacy target-date changes retain existing semantics.", "historical_test_marker": "TEST-202609", "scope": "/System/Business/", diff --git a/tools/lifecycle-contract.test.mjs b/tools/lifecycle-contract.test.mjs index a4642b2..251f5d5 100644 --- a/tools/lifecycle-contract.test.mjs +++ b/tools/lifecycle-contract.test.mjs @@ -1374,7 +1374,7 @@ test('passenger explicit server success is terminal without a post-save row requ assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/); assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/); assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.183'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.184'/); }); test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => { @@ -1584,7 +1584,7 @@ test('lifecycle execution keeps the MV3 worker alive until the durable task sett assert.match(background, /arrangement_resource_candidate_data_missing/); }); -test('extension 0.5.183 keeps reload dormant unless its durable execution state is fully idle', async () => { +test('extension 0.5.184 keeps reload dormant unless its durable execution state is fully idle', async () => { const [background, bridge] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8') @@ -2035,7 +2035,7 @@ test('delete guard distinguishes independent, shared child, and shared parent ro for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8')); - assert.equal(mapping.current_extension_version, '0.5.183'); + assert.equal(mapping.current_extension_version, '0.5.184'); assert.equal( mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'], 'ertrenshu' @@ -2419,10 +2419,10 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/); assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/); assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/); - assert.equal(extensionManifest.version, '0.5.183'); - assert.match(inpage, /version: '0\.5\.183'/); - assert.match(teamBatchInpage, /version: '0\.5\.183'/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.183'/); + assert.equal(extensionManifest.version, '0.5.184'); + assert.match(inpage, /version: '0\.5\.184'/); + assert.match(teamBatchInpage, /version: '0\.5\.184'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.184'/); assert.match(inpage, /function strictIsoDate\(value\)/); assert.match(inpage, /const startDate = strictIsoDate\(current\('riqi'\)\)/); assert.match(inpage, /const endDate = strictIsoDate\(current\('riqis'\)\)/); diff --git a/tools/native-list-readiness.test.mjs b/tools/native-list-readiness.test.mjs new file mode 100644 index 0000000..4643b68 --- /dev/null +++ b/tools/native-list-readiness.test.mjs @@ -0,0 +1,164 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); +const testSource = source.replace(' resolveNativeListSearchValues,', ' testSearch: searchMainPage,\n resolveNativeListSearchValues,'); + +function fixture({ framed = true, tick = () => {} } = {}) { + let now = 0; + const calls = []; + const state = { current: null, framePresent: framed, inaccessible: false }; + function list({ path = '/System/Business/orders.asp', complete = true, loader = true, button = true, disabled = false } = {}) { + const listeners = new Map(); + const fields = new Map(); + const search = { isConnected: true, disabled }; + for (const name of ['S_chufariqi', 'S_chufarizhi', 'S_tuanxuhao', 'S_kehuming', 'S_chanpinming', 'S_youkexinxi', 'S_lingdui', 'S_zhuangtai']) fields.set(name, { value: '' }); + const doc = { + location: { pathname: path, href: `https://erp.invalid${path}` }, readyState: complete ? 'complete' : 'loading', + querySelector(selector) { + if (selector.startsWith('#SearchButton')) return button ? search : null; + return fields.get(selector.match(/^#([^,]+)/)?.[1]) || null; + }, + querySelectorAll: () => [], + fields, search + }; + const events = { on: (name, callback) => listeners.set(name.split('.')[0], callback), off: () => listeners.clear() }; + const jq = () => events; + jq.ajax = () => {}; + jq.active = 0; + doc.defaultView = { document: doc, jQuery: jq }; + if (loader) doc.defaultView.AjaxLoadData = () => { + calls.push({ document: doc, identifier: fields.get('S_tuanxuhao').value }); + const xhr = { status: 200 }; + const data = new URLSearchParams({ Act: 'PrintGridList', ...Object.fromEntries([...fields].map(([name, field]) => [name, field.value])) }); + listeners.get('ajaxSend')?.({}, xhr, { url: '/System/Dat/order.asp', data: data.toString() }); + listeners.get('ajaxComplete')?.({}, xhr); + }; + return doc; + } + const initial = list(); + state.current = initial; + const frame = { get contentDocument() { if (state.inaccessible) throw new Error('fixture access denied'); return state.current; } }; + const root = framed ? { + location: { pathname: '/System/Mainlt.asp' }, + getElementById: () => state.framePresent ? frame : null, + querySelector: () => null + } : initial; + if (!framed) { root.getElementById = () => null; } + const window = { document: root, location: root.location }; + vm.runInNewContext(testSource, { + window, document: root, location: root.location, URL, URLSearchParams, + Date: class extends Date { static now() { return now; } }, + performance: { now: () => now }, + setTimeout(callback, delay) { now += delay; tick({ now, state, initial }); callback(); } + }); + return { api: window.LTJTOrderAssistant, list, initial, root, state, calls, elapsed: () => now, + run: () => window.LTJTOrderAssistant.testSearch(initial, { S_tuanxuhao: 'TEST-LIST-001', S_zhuangtai: '' }) }; +} + +test('ready native list searches once and retains exact request evidence', async () => { + for (const framed of [true, false]) { + const f = fixture({ framed }); + const result = await f.run(); + assert.equal(result, f.initial); + assert.equal(f.calls.length, 1); + assert.equal(f.calls[0].identifier, 'TEST-LIST-001'); + assert.equal(result.__ltjtNativeListSearchEvidence.completed, true); + } +}); + +test('a loading same-path Document replacement is reacquired before searching', async () => { + let replacement; + const f = fixture({ tick({ now, state }) { if (now >= 500) state.current = replacement; } }); + f.initial.readyState = 'loading'; + replacement = f.list(); + const result = await f.run(); + assert.equal(result, replacement); + assert.equal(f.calls.length, 1); + assert.equal(f.calls[0].document, replacement); + assert.equal(f.initial.fields.get('S_tuanxuhao').value, '', 'detached document must not be filled'); + assert.equal(result.__ltjtNativeListSearchEvidence.readiness.document_replacements, 1); +}); + +test('replaced same-path document can finish loading later without using its predecessor', async () => { + let replacement; + const f = fixture({ tick({ now, state }) { + if (now >= 100) state.current = replacement; + if (now >= 1200) replacement.readyState = 'complete'; + } }); + f.initial.readyState = 'loading'; + replacement = f.list({ complete: false }); + assert.equal(await f.run(), replacement); + assert.equal(f.elapsed(), 1200); +}); + +for (const [name, change, missing] of [ + ['unfinished page', f => { f.initial.readyState = 'loading'; }, 'document_complete'], + ['missing loader', f => { delete f.initial.defaultView.AjaxLoadData; }, 'loader_ready'], + ['disabled button', f => { f.initial.search.disabled = true; }, 'search_button_ready'], + ['detached button', f => { f.initial.search.isConnected = false; }, 'search_button_ready'], + ['wrong page', f => { f.state.current = f.list({ path: '/System/login.asp' }); }, 'path_matched'], + ['missing document', f => { f.state.current = null; }, 'document_present'], + ['removed frame', f => { f.state.framePresent = false; }, 'document_present'], + ['inaccessible frame', f => { f.state.inaccessible = true; }, 'document_accessible'] +]) test(`native readiness stops without searching: ${name}`, async () => { + const f = fixture(); change(f); + await assert.rejects(f.run(), error => { + assert.match(error.message, /native_list_search_not_ready/); + assert.ok(error.message.includes(missing)); + assert.equal(error.nativeListReadiness[missing], false); + assert.ok(!JSON.stringify(error.nativeListReadiness).includes('TEST-LIST')); + return true; + }); + assert.equal(f.calls.length, 0); + assert.equal(f.elapsed(), 15000); +}); + +test('document replacement during prior Ajax wait fails closed without searching either page', async () => { + const f = fixture({ tick({ state, initial }) { state.current = f.list(); initial.defaultView.jQuery.active = 0; } }); + f.initial.defaultView.jQuery.active = 1; + await assert.rejects(f.run(), /native_list_search_document_changed/); + assert.equal(f.calls.length, 0); +}); + +test('missing button is diagnosed separately from a ready document and loader', async () => { + const f = fixture(); f.state.current = f.list({ button: false }); + await assert.rejects(f.run(), error => error.nativeListReadiness.search_button_ready === false + && error.nativeListReadiness.document_complete === true && error.nativeListReadiness.loader_ready === true); + assert.equal(f.calls.length, 0); +}); + +test('a page change during the native search is rejected instead of using stale result rows', async () => { + const f = fixture(); const load = f.initial.defaultView.AjaxLoadData; + f.initial.defaultView.AjaxLoadData = () => { load(); f.state.current = f.list(); }; + await assert.rejects(f.run(), /native_list_search_document_changed/); + assert.equal(f.calls.length, 1, 'only the original read-only search is issued; no retry'); + assert.equal(f.initial.__ltjtNativeListSearchEvidence, undefined); +}); + +test('the real resolution failure carries explicit no-write proof and value-free readiness details', async () => { + const f = fixture(); delete f.initial.defaultView.AjaxLoadData; + const report = await f.api.resolveLifecycleOperation({ action: 'arrangement_hotel', data: { existing_refs: { identifier: 'TEST-LIST-001' } } }); + assert.equal(report.status, 'erp_resolution_blocked'); + assert.equal(report.no_erp_write, true); + assert.equal(report.write_attempted, false); + assert.equal(report.native_request, null); + assert.equal(report.preflight.stage, 'erp_readonly_resolution'); + assert.equal(report.preflight.native_list_readiness.loader_ready, false); + assert.equal(f.calls.length, 0); +}); + +test('the worker describes readiness failures accurately while preserving not-found errors', () => { + const worker = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); + const fn = worker.slice(worker.indexOf('function erpResolutionFailure('), worker.indexOf('function lifecycleRouteFailure(')); + const context = vm.createContext({}); vm.runInContext(fn, context); + for (const cause of ['native_list_search_not_ready', 'native_list_search_not_ready:loader_ready', 'native_list_search_document_changed']) { + const failure = context.erpResolutionFailure({}, { blockers: [`erp_resolution_navigation_failed:${cause}`] }); + assert.equal(failure.errorCode, 'erp_list_search_not_ready'); + assert.match(failure.message, /未写入 ERP/); + assert.doesNotMatch(failure.message, /确认 ERP 已登录/); + } + assert.equal(context.erpResolutionFailure({}, { blockers: ['erp_resolution_candidate_count:0'] }).errorCode, 'erp_target_not_found'); +});