diff --git a/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md b/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md index 44f112d..426183a 100644 --- a/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md +++ b/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md @@ -199,3 +199,14 @@ Read: memory-index, project-positioning, current-state latest September sections - Local activation: backed up only the editing9/17 review, then used the existing internal append-only reanalysis operation with expected revision2. Authenticated API now reports editing revision3/pending0/total0/can_finalize=true,18 cancellations excluded, no job and no finalize intent. Original data SHA remains `b9e115470f3ce4d1a31fde03308c18f0854c9daef5a8beb71d8ca5b997cdc831`; original manifest SHA remains `8efb54d2737d15d837295ffa7f5e5bd1ff0d467d89fb1e956de6b1055a8c076f`.10/7 remains finalized source revision4 and its existing price-review job, unchanged. No Oracle call, service restart, policy-pin change, Finance write, real daily/monthly generation, remote push or primary-checkout edit. - UI verification: preserved the original10/7 price panel and opened a separate local result tab. The pending-date button was initially disabled after restoration; choosing9/17 refreshed its enabled state, then opening the existing pending task made no new download. Page visibly shows9/17,0/0 confirmed,18 excluded cancellations, “没有待完善字段”, and an enabled “确认并生成日报”. Did not click generation. Both result and existing price tabs are retained. Private screenshot: `/Users/chillishark/Library/Application Support/ARR2.0/production-validation-20261007/sept17-optional-review-cleared-20261008.png`. - Private audit: `production-validation-20261007/sept17-optional-reanalysis-dccb6cbb03c4/{receipt,activation}.json`; replay manifest `7e4ec018d8898864c4fabacc4e2fba8be8e917704b95bc4e6fc7467d8c635783`; optional-only projected data `8afddb5d08bbc0c8814c1036db6b0be91aa45cb25676bc25731e642b8581ad9f`; pre-append backup `ohip-production-57106-20261008/sept17-source-reanalysis-backup-20261008-123341`. Raw guest data stays outside Git. Promotion candidate: explain old pending captures require explicit source-policy interpretation rather than blanket staff confirmation; retain current optional/required distinction. Broader report-scope alignment remains deferred. + +## Same-task Follow-up: September16 Submission Recovery + +- User reports selecting9/16 and seeing “尚未查到任务,可继续提交原请求”. Concurrent gate Passed for resumed task/feature/codex/owned checkout/base2417b1a with no peers. Project Context Loaded: retained required memory/positioning/decisions/architecture/domain/evidence/reflection/commitments/stale context plus current task record and planning-gate instructions. Relevant boundaries: two input paths, original pricing/Finance identity, independent report dates, no duplicate source acquisition after an uncertain response. Primary unknown dirty docs remain untouched; September restrictions remain superseded only by explicit October authorization. Planning gate Passed. +- Read-only evidence: current live queue has only9/15,10/7,9/17; user request88cf3094edc2e82dec679e27c4bd3e57 is absent. At20:53:21 local service log records POST /api/arr-downloads then GET for that ID. Existing logs intentionally retain path only and omit HTTP status/error bodies, so exact historical refusal cannot be reconstructed. Browser-control copies still show10/7 and9/17; the user-visible older tab connection times out. Do not assert a guessed historical failure, lack of9/16 Oracle data or an Oracle outage. +- Confirmed code issue: submission catches400/409/503 but not403 for toast, then GET404 replaces any known submit rejection with generic not_received. A stale CSRF token after session/cookie changes can cause this precise path even while GET remains authenticated. It is a reproducible candidate, not proven historical cause. +- Plan: retry a mutation once only after explicit403/SESSION_INVALID, refresh token from authenticated /api/session only for the same username, preserve exact body/path/intent, and retain a definite rejection reason when reconciliation finds no task. Never retry network/500/other403 automatically. Add focused synthetic regression checks and verify local UI without submitting a new Oracle read or changing saved price/source decisions. +- Outcome: shared API refreshes a stale token once only after an explicit403/SESSION_INVALID. Authenticated /api/session must identify the same original username and supply a valid token; the retry preserves method/path/body/headers/returnEnvelope. A second rejection,401, changed username, malformed token, network loss,500 or any other403 does not trigger another mutation. Submission keeps a known4xx error reason when GET reconciliation returns404; generic not_received now clearly directs explicit retry. Existing uncertain-intent identity and independent-date behavior stay intact. +- Verification:56 JavaScript submission/source-review cases passed (8 new focused recovery/boundary cases), including exact body/request-ID retention, one retry limit,401/user-change/invalid-token refusal, other403 refusal, price decision body/extra-header/envelope preservation,500 uncertainty and existing lost-response/404 replay. Both scripts passed node syntax checks and Git whitespace checks. Independent agent reproduced stale-token POST403→GET404 in the actual frontend sandbox and a pure-memory backend: no queue.create call; then reviewed the final retry boundaries with no blocking issues. This establishes the bug mechanism, not the unknown historical HTTP response for the user's submission. +- Local verification: static changes are served immediately without restart. Reloaded the agent result tab only, selected9/16, and confirmed enabled “下载并处理” and “可下载所选日期的报表”;9/17 remains in the pending-date list and10/7 stays at0/2 price decisions. Did not press download, retry, finalize, or save prices; no new Oracle read/Finance write/task creation. The older user-visible tab's failed browser connection was not bypassed; user should refresh that page to receive the fix. Private screenshot `production-validation-20261007/sept16-submission-ready-20261008.png`; existing price tab and result tab retained. +- Follow-up/promotion: exact9/16 historical rejection remains unknown because current path-only logs omit status. Explain this evidence limit rather than claim Oracle no-data or prove session rejection for that event. User can explicitly retry the original9/16 intent from the refreshed page; then assess authoritative queue progress. Canonical UI/API recovery documentation should record bounded same-user refresh and preserved definite-error details at integration. No remote push, production deployment, primary-checkout edit or report generation occurred. diff --git a/arr_web/static/app.js b/arr_web/static/app.js index 4518f5b..cdc6568 100644 --- a/arr_web/static/app.js +++ b/arr_web/static/app.js @@ -12,6 +12,7 @@ arrDownloadLoaded: false, arrDownloadTask: null, arrDownloadIntent: null, + arrDownloadSubmissionError: null, arrDownloadBusy: false, arrDownloadLoading: false, arrDownloadConfigLoading: false, @@ -415,11 +416,14 @@ let status = !state.arrDownloadLoaded ? "connecting" : !state.arrDownloadReady ? "unavailable" : task?.status || "ready"; if (state.arrDownloadConfigDisconnected) status = "service_reconnecting"; if (state.arrDownloadDisconnected || pending) status = "reconnecting"; + const rejected = status === "not_received" && Boolean(task?.submission_error); const feedback = $("#arr-download-feedback"); - feedback.className = `arr-download-feedback${arrDownloadActive() ? " is-running" : status === "succeeded" ? " is-success" : ["needs_review", "needs_data_review"].includes(status) ? " is-review" : ["failed", "interrupted", "reconnecting", "service_reconnecting", "date_unavailable"].includes(status) ? " is-error" : ""}`; + feedback.className = `arr-download-feedback${arrDownloadActive() ? " is-running" : status === "succeeded" ? " is-success" : ["needs_review", "needs_data_review"].includes(status) ? " is-review" : ["failed", "interrupted", "reconnecting", "service_reconnecting", "date_unavailable"].includes(status) || rejected ? " is-error" : ""}`; const taskError = !task?.job_id && status === "failed" && task.error_code === "ARR_SOURCE_FETCH_FAILED" ? "source_failed" : !task?.job_id && status === "interrupted" && task.error_code === "ARR_DOWNLOAD_INTERRUPTED" ? "source_interrupted" : status; - const text = I18N.t(`arr_download.${taskError}`); + const text = rejected + ? I18N.t("arr_download.submission_rejected", { reason: task.submission_error }) + : I18N.t(`arr_download.${taskError}`); const taskDate = task?.report_date || state.arrDownloadIntent?.report_date; $("#arr-download-status").textContent = taskDate ? `${taskDate} · ${text}` : text; $("#arr-download-review").hidden = task?.status !== "needs_review" || !task.job_id; @@ -444,6 +448,7 @@ } async function acceptARRDownloadTask(task, { sync = true } = {}) { + state.arrDownloadSubmissionError = null; state.arrDownloadTask = task; rememberARRPendingReview(task); state.arrDownloadDisconnected = false; @@ -481,6 +486,9 @@ if (state.arrDownloadIntent?.request_id !== intent.request_id) return; if (error.status === 404) { state.arrDownloadTask = { ...intent, status: "not_received", can_retry: false }; + if (state.arrDownloadSubmissionError?.request_id === intent.request_id) { + state.arrDownloadTask.submission_error = state.arrDownloadSubmissionError.message; + } state.arrDownloadDisconnected = false; } else { state.arrDownloadDisconnected = true; @@ -514,6 +522,7 @@ state.arrDownloadContextId = config.context_id; state.arrDownloadRestored = false; state.arrDownloadIntent = null; + state.arrDownloadSubmissionError = null; state.arrDownloadTask = null; state.arrDownloadDisconnected = false; state.arrDownloadSynced = ""; @@ -578,6 +587,7 @@ intent = { request_id: [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""), report_date: reportDate }; } rememberARRIntent(intent); // Persist before the HTTP call, including a lost response. + state.arrDownloadSubmissionError = null; state.arrDownloadTask = null; resetARRDataReview(); state.arrDownloadBusy = true; @@ -600,9 +610,12 @@ return; } // A dropped response is not evidence of a failed download/commit. + if (error.status >= 400 && error.status < 500) { + state.arrDownloadSubmissionError = { request_id: intent.request_id, message: error.message }; + } state.arrDownloadDisconnected = true; if (error.status === 503) state.arrDownloadReady = false; - if ([400, 409, 503].includes(error.status)) showToast(error.message, true); + if ([400, 403, 409, 503].includes(error.status)) showToast(error.message, true); await loadARRDownloadTask(); } finally { state.arrDownloadBusy = false; @@ -873,7 +886,7 @@ } async function api(path, options = {}) { - const { returnEnvelope = false, ...requestOptions } = options; + const { returnEnvelope = false, csrfRetried = false, ...requestOptions } = options; const headers = new Headers(requestOptions.headers || {}); if (requestOptions.method && requestOptions.method !== "GET") headers.set("X-ARR-CSRF", state.csrf); const response = await fetch(path, { ...requestOptions, headers, credentials: "same-origin" }); @@ -892,6 +905,18 @@ const requestError = new Error(I18N?.errorMessage(error.code, error.message) || error.message || "请求未完成"); requestError.code = error.code || ""; requestError.status = response.status; + if (response.status === 403 && error.code === "SESSION_INVALID" && !csrfRetried + && requestOptions.method && requestOptions.method !== "GET") { + // A fresh login in another tab changes the shared cookie, while this + // page may retain the old token. The rejected mutation did not run. + // Refresh only for the same user, then retry the exact request once. + let session; + try { session = await api("/api/session"); } catch (_) { throw requestError; } + if (session?.username !== state.arrDownloadUsername || !state.arrDownloadUsername + || typeof session.csrf_token !== "string" || !session.csrf_token) throw requestError; + state.csrf = session.csrf_token; + return api(path, { ...options, csrfRetried: true }); + } throw requestError; } return returnEnvelope ? payload : payload.data; diff --git a/arr_web/static/i18n.js b/arr_web/static/i18n.js index 6f5bc69..06440b8 100644 --- a/arr_web/static/i18n.js +++ b/arr_web/static/i18n.js @@ -132,7 +132,8 @@ "arr_download.failed": ["任务未完成,请查看任务日志", "Task failed. Check the task log.", "งานไม่สำเร็จ โปรดดูบันทึกงาน"], "arr_download.interrupted": ["任务中断,继续原任务以核对处理结果", "Task interrupted. Resume to reconcile the result.", "งานขัดจังหวะ ดำเนินงานเดิมต่อเพื่อตรวจสอบผล"], "arr_download.reconnecting": ["正在重新查询原任务状态…", "Checking the original task status…", "กำลังตรวจสอบสถานะงานเดิม…"], - "arr_download.not_received": ["尚未查到任务,可继续提交原请求", "Task not found yet. Resubmit the original request.", "ยังไม่พบงาน สามารถส่งคำขอเดิมอีกครั้ง"], + "arr_download.not_received": ["下载任务尚未建立,请点击下方按钮重试", "The download task has not been created. Use the button below to retry.", "ยังไม่ได้สร้างงานดาวน์โหลด โปรดกดปุ่มด้านล่างเพื่อลองอีกครั้ง"], + "arr_download.submission_rejected": ["提交未成功:{reason}。下载任务尚未建立,请重试。", "Submission failed: {reason}. No download task has been created. Please retry.", "ส่งคำขอไม่สำเร็จ: {reason} ยังไม่ได้สร้างงานดาวน์โหลด โปรดลองอีกครั้ง"], "arr_download.date_unavailable": ["本机模拟没有所选日期的数据,请重新选择日期。", "No local simulation data for this date. Choose another date.", "ไม่มีข้อมูลจำลองในเครื่องสำหรับวันที่นี้ โปรดเลือกวันที่อื่น"], "arr_download.submitting": ["正在提交…", "Submitting…", "กำลังส่ง…"], "arr_download.running": ["任务进行中", "In progress", "กำลังดำเนินการ"], diff --git a/tests/javascript/arr_download_submission.cjs b/tests/javascript/arr_download_submission.cjs index 2efe769..264d8bc 100644 --- a/tests/javascript/arr_download_submission.cjs +++ b/tests/javascript/arr_download_submission.cjs @@ -27,6 +27,102 @@ test('old not_received intent is released only after the explicit source-date re assert.equal(h.element('#arr-download-date').disabled,false); }); +test('stale session token refreshes for the same user and retries the exact submission once',async()=>{ + let posts=0; + const h=harness((url,options)=>{ + if(url==='/api/session') return response(200,{username:'operator',csrf_token:'fresh-fixture'}); + assert.equal(url,'/api/arr-downloads'); + if(++posts===1) return response(403,null,'SESSION_INVALID'); + return response(202,{...JSON.parse(options.body),status:'queued',job_id:null,can_retry:false}); + }); + await h.submit(); + assert.equal(h.calls.length,3); + assert.equal(h.calls[1].url,'/api/session'); + assert.equal(h.calls[1].method,'GET'); + assert.equal(h.calls[0].body,h.calls[2].body); + assert.equal(h.calls[0].csrf,'fixture'); + assert.equal(h.calls[2].csrf,'fresh-fixture'); + assert.equal(h.state.arrDownloadTask.status,'queued'); + assert.equal(h.state.arrDownloadSubmissionError,null); + assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(h.calls[0].body).request_id); +}); + +test('a still-invalid session stops after one retry and keeps the rejection reason and identity',async()=>{ + const h=harness((url,options)=>{ + if(url==='/api/session') return response(200,{username:'operator',csrf_token:'fresh-fixture'}); + return options.method==='POST' ? response(403,null,'SESSION_INVALID') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND'); + }); + await h.submit(); + const posts=h.calls.filter(c=>c.method==='POST'); + assert.equal(posts.length,2); + assert.equal(posts[0].body,posts[1].body); + assert.equal(h.calls.filter(c=>c.url==='/api/session').length,1); + assert.equal(h.state.arrDownloadTask.status,'not_received'); + assert.equal(h.state.arrDownloadTask.submission_error,'SESSION_INVALID'); + assert.match(h.element('#arr-download-status').textContent,/arr_download.submission_rejected/); + assert.match(h.element('#arr-download-feedback').className,/is-error/); + assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(posts[0].body).request_id); +}); + +for(const [description,sessionResponse] of [ + ['requires login',()=>response(401,null,'SESSION_INVALID')], + ['switches user',()=>response(200,{username:'different-user',csrf_token:'fresh-fixture'})], + ['has no valid token',()=>response(200,{username:'operator',csrf_token:''})], +]) { + test(`session refresh that ${description} cannot resend the mutation`,async()=>{ + const h=harness((url,options)=>{ + if(url==='/api/session') return sessionResponse(); + return options.method==='POST' ? response(403,null,'SESSION_INVALID') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND'); + }); + await h.submit(); + assert.equal(h.calls.filter(c=>c.method==='POST').length,1); + assert.equal(h.state.csrf,'fixture'); + assert.equal(h.state.arrDownloadTask.submission_error,'SESSION_INVALID'); + assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(h.calls[0].body).request_id); + }); +} + +test('a different forbidden response is displayed without refreshing or resubmitting',async()=>{ + const h=harness((url,options)=>options.method==='POST' + ? response(403,null,'REPLAY_ORIGIN_REJECTED') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND')); + await h.submit(); + assert.equal(h.calls.length,2); + assert.equal(h.calls.some(c=>c.url==='/api/session'),false); + assert.equal(h.state.arrDownloadTask.submission_error,'REPLAY_ORIGIN_REJECTED'); + assert.equal(h.state.arrDownloadTask.status,'not_received'); +}); + +test('session recovery preserves a price decision body, extra headers and full response envelope',async()=>{ + let posts=0; + const payload={case_id:'fixture-case',revision:7,real_price:'1500'}; + const envelope={ok:true,data:{revision:8},trace:'fixture-trace'}; + const h=harness((url,options)=>{ + if(url==='/api/session') return response(200,{username:'operator',csrf_token:'fresh-fixture'}); + assert.equal(url,'/api/jobs/fixture-job/review/items/1'); + assert.equal(options.headers.get('X-Fixture'),'retained'); + if(++posts===1) return response(403,null,'SESSION_INVALID'); + return {status:200,ok:true,json:async()=>envelope}; + }); + const result=await h.api('/api/jobs/fixture-job/review/items/1',{ + method:'POST',headers:{'Content-Type':'application/json','X-Fixture':'retained'}, + body:JSON.stringify(payload),returnEnvelope:true, + }); + assert.deepEqual(result,envelope); + assert.equal(h.calls[0].body,h.calls[2].body); + assert.deepEqual(JSON.parse(h.calls[2].body),payload); + assert.equal(posts,2); +}); + +test('a server failure remains uncertain without automatic session or mutation retry',async()=>{ + const h=harness((url,options)=>options.method==='POST' + ? response(500,null,'INTERNAL_ERROR') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND')); + await h.submit(); + assert.equal(h.calls.length,2); + assert.equal(h.state.arrDownloadSubmissionError,null); + assert.equal(h.state.arrDownloadTask.submission_error,undefined); + assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(h.calls[0].body).request_id); +}); + test('lost response and 404 keep original identity for explicit resubmission',async()=>{ let posts=0; const h=harness((url,options)=>{ diff --git a/tests/javascript/helpers/arr_ui_harness.cjs b/tests/javascript/helpers/arr_ui_harness.cjs index aded347..667c2c2 100644 --- a/tests/javascript/helpers/arr_ui_harness.cjs +++ b/tests/javascript/helpers/arr_ui_harness.cjs @@ -20,9 +20,9 @@ function harness(respond) { window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code},crypto:webcrypto, setTimeout(){return 1;},clearTimeout(){},location:{replace(){}}}, localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>{storageReads.push(k);return storage.get(k);},removeItem:k=>storage.delete(k)}, - fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body});return respond(url,options,calls);}, + fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body,csrf:options.headers.get('X-ARR-CSRF')});return respond(url,options,calls);}, }); - const exports='state, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview'; + const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview'; vm.runInContext(source.replace(boot,` globalThis.subject = {${exports}};\n})();`),context); const subject=context.subject; Object.assign(subject.state,{arrDownloadReady:true,arrDownloadLoaded:true,arrDownloadStorageKey:'test',arrDownloadUsername:'operator',csrf:'fixture'});