{"record":{"id":"16a2fbacc6b7c8a1","repo":"santifer/career-ops","slug":"apply-session-not-found-it-may-have-expired","errorCode":null,"errorMessage":"apply session not found (it may have expired)","messagePattern":"apply session not found \\(it may have expired\\)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"web/src/lib/apply/session.ts","lineNumber":368,"sourceCode":"export type FillStep = { fieldId: string; label: string; ok: boolean; thumb?: string };\n\n/** True for a file field that wants the candidate's résumé/CV (vs. cover letter,\n *  portfolio, or a generic attachment we leave for the user). */\nfunction isResumeField(f: ApplyField): boolean {\n  return f.type === \"file\" && /resume|résumé|\\bcv\\b|curriculum|lebenslauf|currículum/i.test(f.label || \"\");\n}\n\n/** Fill the real form with verified answers, screenshotting after each field.\n *  Attaches the tailored CV PDF to résumé/CV file fields (cvPath). NEVER clicks a\n *  submit/apply control — only fills/selects/checks/attaches. */\nexport async function fillSession(\n  id: string,\n  answers: Record<string, string>,\n  fieldsMeta: ApplyField[],\n  cvPath?: string,\n): Promise<{ steps: FillStep[]; navigated: boolean; issues: ApplyIssue[] }> {\n  const s = SESSIONS.get(id);\n  if (!s) throw new Error(\"apply session not found (it may have expired)\");\n  const byId = new Map(fieldsMeta.map((f) => [f.id, f]));\n  const steps: FillStep[] = [];\n  // Belt-and-suspenders: if filling ever navigates the page (i.e. something got\n  // submitted), the URL path changes. We never submit by construction, but we\n  // report it so the caller can flag it instead of silently \"succeeding\".\n  const startPath = (() => {\n    try {\n      return new URL(s.frame.url()).pathname;\n    } catch {\n      return s.frame.url();\n    }\n  })();\n\n  const shoot = async () => {\n    try {\n      const buf = await s.page.screenshot({ type: \"jpeg\", quality: 38 });\n      return `data:image/jpeg;base64,${buf.toString(\"base64\")}`;\n    } catch {","sourceCodeStart":350,"sourceCodeEnd":386,"githubUrl":"https://github.com/santifer/career-ops/blob/9b17a8ac97b398a496b38e423ae24e433b43254f/web/src/lib/apply/session.ts#L350-L386","documentation":"Thrown by `fillSession` when the in-memory session record for the given `id` is not in the `SESSIONS` map. Sessions live in process memory (a `Map`) and are pruned by an idle/age policy, so a session that existed at open time can vanish before the user calls fill. The message explicitly hints expiry as the likely cause.","triggerScenarios":"Calling `fillSession(id, ...)` with an `id` that was never opened, was already pruned by `prune()` (idle expiry), or whose process restarted (in-memory map wiped). Also if the caller mis-keys the id (typo, stale UI state, double-submit after close).","commonSituations":"Long pause between `openSession` and `fillSession` (user stepped away, idle timer fired); dev server hot-reload restarting the process; multiple tabs / race where one tab closed the session; copy-pasting an old session id from logs; calling fill after `handoffSession` already ended the session.","solutions":["Re-open the session with `openSession(url)` to get a fresh `id`, then call `fillSession` promptly with the new id.","Treat the error as recoverable in the UI: show 'session expired, click to reopen' and re-run open+fill in sequence.","Reduce the gap between open and fill — drive them as one user action so the idle pruner cannot fire in between.","If sessions expire too aggressively, review the `prune()`/`scheduleIdleClose()` thresholds and raise them to match your UX.","Persist the answers client-side so a re-open doesn't lose the user's prepared answers."],"exampleFix":"// before — stale id reused across a long pause\nconst { id } = await openSession(url);\n// ...minutes later...\nawait fillSession(id, answers, fields); // throws\n// after — reopen on expiry\ntry { await fillSession(id, answers, fields); }\ncatch (e) {\n  if (/expired/.test(e.message)) {\n    const fresh = await openSession(url);\n    await fillSession(fresh.id, answers, fields);\n  } else throw e;\n}","handlingStrategy":"retry","validationCode":"// Track liveness client-side so you don't call fill on a dead id.\nconst open = await openSession(url);\nconst openedAt = Date.now();\n// If user idle > (prune window), reopen before fill:\nconst id = (Date.now() - openedAt > SESSION_TTL_MS) ? (await openSession(url)).id : open.id;","typeGuard":null,"tryCatchPattern":"async function fillOrReopen(url, answers, fields) {\n  let id = currentSessionId;\n  try {\n    return await fillSession(id, answers, fields);\n  } catch (e) {\n    if (/expired|not found/i.test(e.message)) {\n      const fresh = await openSession(url);\n      return await fillSession(fresh.id, answers, fields);\n    }\n    throw e;\n  }\n}","preventionTips":["Drive open → fill as one user action with no long pause.","Persist answers client-side so reopening doesn't lose data.","Show 'session expired — reopening' in the UI instead of surfacing the raw error.","Keep `SESSION_TTL_MS` in sync with the prune threshold in session.ts."],"tags":["session","expiry","state","apply","in-memory"],"backgroundTag":null,"analyzedSha":"9b17a8ac97b398a496b38e423ae24e433b43254f","analyzedAt":"2026-08-13T00:48:39.135Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}