fix: recover stale sessions without masking ARR submission errors
This commit is contained in:
1 parent
5acedc02e0
commit
9487634f83
5 files changed
+140
-7
No files matched your search
@@ -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.
|
||||
+29
-4
@@ -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;
|
||||
|
||||
@@ -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", "กำลังดำเนินการ"],
|
||||
|
||||
@@ -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)=>{
|
||||
|
||||
@@ -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'});
|
||||
|
||||
Reference in new issue
Block a user