// 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);