// Job lifecycle, resume storage, and share-link ownership for the no-build UI. // Loaded before sortof-shared.jsx and sortof-app.jsx. (function installSortofJobClient(global) { const RESUME_STORAGE_KEY = 'sortof.resume.v2'; const RESUME_VERSION = 2; const RESUME_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; const POLL_INTERVAL_MS = 2500; const MAX_TRANSIENT_RETRIES = 2; let configuredFetch = null; function getFetch() { if (configuredFetch) return configuredFetch; if (typeof global.__SORTOF_FAKE_FETCH__ === 'function') { return global.__SORTOF_FAKE_FETCH__; } return global.fetch.bind(global); } function setFetch(fetchImpl) { configuredFetch = typeof fetchImpl === 'function' ? fetchImpl : null; } function classifyHttpFailure(status) { if (status === 404) { return { kind: 'missing', status, retryable: false }; } if (status === 0 || status === 429 || status >= 500) { return { kind: 'error', status, retryable: true }; } return { kind: 'error', status, retryable: false }; } function createResultOwner() { let current = { generation: 0, jobId: null }; return { establish(jobId = null) { current = { generation: current.generation + 1, jobId }; return { ...current }; }, claimJob(ownership, jobId) { if ( ownership.generation !== current.generation || ownership.jobId !== current.jobId ) { return null; } current = { generation: current.generation, jobId: jobId || null }; return { ...current }; }, isCurrent(ownership) { return Boolean( ownership && ownership.generation === current.generation && ownership.jobId === current.jobId, ); }, snapshot() { return { ...current }; }, }; } function resultKey(ownership) { if (!ownership) return ''; return `${ownership.generation}:${ownership.jobId || ''}`; } function filterBranchSelections(selections, modDb) { const available = new Map(); for (const mod of (modDb || [])) { const wsid = String(mod.wsid || ''); const modId = String(mod.modId || mod.mod_id || mod.id || ''); if (!wsid || !modId) continue; if (!available.has(wsid)) available.set(wsid, new Set()); available.get(wsid).add(modId); } const filtered = {}; for (const [rawWsid, rawModIds] of Object.entries(selections || {})) { const wsid = String(rawWsid); const validForWsid = available.get(wsid); if (!validForWsid || !Array.isArray(rawModIds)) continue; const modIds = [...new Set(rawModIds.map(String).filter(id => validForWsid.has(id)))]; if (modIds.length) filtered[wsid] = modIds; } return filtered; } async function pollJobOnce(jobId, options = {}) { const fetchImpl = options.fetchImpl || getFetch(); const source = options.interactionSource; const query = source ? `?interaction_source=${encodeURIComponent(source)}` : ''; try { const response = await fetchImpl(`/api/jobs/${jobId}${query}`, { headers: { 'X-Job-Token': options.jobToken }, }); if (!response.ok) return classifyHttpFailure(response.status); return { kind: 'ok', status: response.status, body: await response.json() }; } catch (error) { console.warn('pollJobOnce: network error', error); return { kind: 'error', status: 0, retryable: true, error }; } } function pollJobLoop(jobId, signal, onTick, options = {}) { const intervalMs = options.intervalMs ?? POLL_INTERVAL_MS; const maxTransientRetries = options.maxTransientRetries ?? MAX_TRANSIENT_RETRIES; const setTimer = options.setTimer || global.setTimeout.bind(global); const clearTimer = options.clearTimer || global.clearTimeout.bind(global); const fetchImpl = options.fetchImpl; const interactionSource = options.interactionSource; const jobToken = options.jobToken; return new Promise((resolve) => { let timer = null; let transientFailures = 0; let firstRequest = true; async function tick() { if (signal.aborted) { if (timer) clearTimer(timer); resolve({ kind: 'aborted' }); return; } const result = await pollJobOnce(jobId, { fetchImpl, interactionSource: firstRequest ? interactionSource : null, jobToken, }); firstRequest = false; if (signal.aborted) { resolve({ kind: 'aborted' }); return; } onTick(result); if (result.kind === 'missing') { resolve(result); return; } if (result.kind === 'error') { if (result.retryable && transientFailures < maxTransientRetries) { transientFailures += 1; timer = setTimer(tick, intervalMs * transientFailures); return; } resolve(result); return; } transientFailures = 0; if (result.body.phase === 'done' || result.body.phase === 'failed') { resolve(result); return; } timer = setTimer(tick, intervalMs); } tick(); }); } function getStorage(storage) { if (storage) return storage; try { return global.localStorage; } catch { return null; } } function clearResumeRecord(options = {}) { const storage = getStorage(options.storage); if (!storage) return; try { storage.removeItem(RESUME_STORAGE_KEY); } catch {} } function normalizeResumeRecord(value) { if (!value || value.version !== RESUME_VERSION) return null; if (typeof value.jobId !== 'string' || !value.jobId) return null; if (typeof value.jobToken !== 'string' || !value.jobToken) return null; if (typeof value.input !== 'string' || typeof value.pzBuild !== 'string') return null; if (value.state !== 'active' && value.state !== 'complete') return null; const submittedAtMs = Date.parse(value.submittedAt); if (!Number.isFinite(submittedAtMs)) return null; return { version: RESUME_VERSION, jobId: value.jobId, jobToken: value.jobToken, input: value.input, pzBuild: value.pzBuild, submittedAt: new Date(submittedAtMs).toISOString(), state: value.state, }; } function readResumeRecord(options = {}) { const storage = getStorage(options.storage); if (!storage) return null; try { const record = normalizeResumeRecord(JSON.parse(storage.getItem(RESUME_STORAGE_KEY))); const nowMs = options.nowMs ?? Date.now(); const retentionMs = options.retentionMs ?? RESUME_RETENTION_MS; if (!record || nowMs - Date.parse(record.submittedAt) > retentionMs) { clearResumeRecord({ storage }); return null; } return record; } catch { clearResumeRecord({ storage }); return null; } } function writeResumeRecord(record, options = {}) { const storage = getStorage(options.storage); const normalized = normalizeResumeRecord({ version: RESUME_VERSION, state: 'active', submittedAt: new Date().toISOString(), ...record, }); if (!storage || !normalized) return null; try { storage.setItem(RESUME_STORAGE_KEY, JSON.stringify(normalized)); return normalized; } catch { return null; } } function markResumeComplete(jobId, options = {}) { const record = readResumeRecord(options); if (!record || record.jobId !== jobId) return null; return writeResumeRecord({ ...record, state: 'complete' }, options); } async function createShareLink(jobId, options = {}) { const fetchImpl = options.fetchImpl || getFetch(); try { const response = await fetchImpl(`/api/jobs/${jobId}/share`, { method: 'POST', headers: { 'X-Job-Token': options.jobToken }, }); if (!response.ok) return classifyHttpFailure(response.status); const body = await response.json(); if (!body || typeof body.url !== 'string' || !body.url) { return { kind: 'error', status: response.status, retryable: false }; } return { kind: 'ok', status: response.status, body }; } catch (error) { return { kind: 'error', status: 0, retryable: true, error }; } } async function copyText(text) { if (!global.navigator?.clipboard?.writeText) { throw new Error('clipboard unavailable'); } await global.navigator.clipboard.writeText(text); } function ShareIcon({ copied }) { if (copied) { return ( ); } return ( ); } function ShareBtn({ jobId, jobToken }) { const [state, setState] = React.useState('idle'); const [message, setMessage] = React.useState(''); const urlRef = React.useRef(null); const timerRef = React.useRef(null); React.useEffect(() => () => { if (timerRef.current) global.clearTimeout(timerRef.current); }, []); React.useEffect(() => { urlRef.current = null; setState('idle'); setMessage(''); }, [jobId, jobToken]); if (!jobId || !jobToken) return null; const resetLater = () => { if (timerRef.current) global.clearTimeout(timerRef.current); timerRef.current = global.setTimeout(() => { setState('idle'); setMessage(''); }, 2200); }; const onClick = async () => { if (state === 'loading') return; setState('loading'); setMessage(''); let url = urlRef.current; if (!url) { const share = await createShareLink(jobId, { jobToken }); if (share.kind !== 'ok') { setState(share.kind === 'missing' ? 'expired' : 'share-error'); setMessage( share.kind === 'missing' ? 'sort expired' : share.retryable ? 'share unavailable. retry' : 'sort cannot be shared', ); return; } url = share.body.url; urlRef.current = url; } try { await copyText(url); setState('copied'); setMessage('link copied'); resetLater(); } catch { setState('copy-error'); setMessage('copy failed. retry'); } }; const label = state === 'loading' ? 'creating link…' : message || 'copy share link'; return ( ); } global.SortofJobClient = { MAX_TRANSIENT_RETRIES, POLL_INTERVAL_MS, RESUME_RETENTION_MS, RESUME_STORAGE_KEY, RESUME_VERSION, ShareBtn, classifyHttpFailure, clearResumeRecord, createResultOwner, createShareLink, filterBranchSelections, markResumeComplete, pollJobLoop, pollJobOnce, readResumeRecord, resultKey, setFetch, writeResumeRecord, }; global.ShareBtn = ShareBtn; })(window);