// Your own hardware: a run on one of the person's own quantum providers (a // device, an account, a simulator), in the desktop app only. // // A provider needs the person's credential, and a browser tab must not hold // one, so in the browser the "Your hardware" switch only says where such a // run goes: the desktop app, or the command line (`wai-quantum run // --provider NAME`). In the desktop app the app holds the credential (in the // operating system's keychain) and the page never sees it: the page lists // the providers, asks for the provider's estimate first, and when the run is // not free asks the person in a dialog before it is submitted. The answer is // a job like every other, its counts measured on the device and sealed. import { el } from './dom.js'; import { t as tr } from './i18n.js'; export const NOTE = tr('hardware.note'); // Whether a plan (the dry run's answer) needs the person's confirmation: any // run not stated free, an unknown cost included. export const needsConfirmation = (plan) => plan?.free !== true; // The estimate in words. export function costText(plan) { const e = plan?.estimate || {}; if (e.unknown) return tr('hardware.cost_unknown', { why: e.unknown }); if (e.free === true) return tr('hardware.free'); const unit = e.unit ? tr('hardware.billed_by', { unit: e.unit }) : ''; const secs = e.seconds !== undefined ? tr('hardware.about_seconds', { seconds: e.seconds }) : ''; return `${e.amount} ${e.currency}${unit}${secs}`; } // What the dialog says before a run that is not free. export function confirmText(plan) { const p = plan?.provider || {}; const c = plan?.plan?.compile || {}; return [ tr('hardware.confirm_run', { name: p.name, device: p.device, provenance: p.provenance }), tr('hardware.confirm_cost', { cost: costText(plan) }), tr('hardware.confirm_compiled', { gates: c.native_gates, basis: c.basis, swaps: c.swaps, shots: plan?.plan?.shots }), tr('hardware.confirm_spend'), ]; } // A run on a provider, through the desktop app's commands. // // The app writes the job down before it sends it, so a job in a provider's // queue outlives the window. The run waits here while the person watches, // asking after it by its id, less often the longer it waits (a device's queue // can take hours). Cancel cancels it at the provider; "Wait in the // background" leaves it there and frees the Studio. Jobs left waiting, from // this window or an earlier one, are followed in the background, and each // one's result goes to the history when it finishes. export class HardwareEngine { constructor(invoke, ask, { first = 2000, most = 60000 } = {}) { this.invoke = invoke; this.ask = ask; this.first = first; this.most = most; this.provider = null; this.where = 'your hardware'; this.current = null; // the id of the job the run waits for this.stop = null; // 'cancel' or 'later', when the person has said this.wake = null; this.watched = new Set(); this.onFinished = null; // (job) => …, for a job followed in the background this.onEnded = null; } // A command's answer; one that failed throws. A job that ended (failed or // cancelled) is an answer, with its `status` and why, not a failure. async call(cmd, args) { const v = JSON.parse(await this.invoke(cmd, args)); if (v && v.error && !v.plan && !v.status) throw new Error(v.error); return v; } async providers() { return (await this.call('providers_list', {})).providers || []; } select(name) { this.provider = name; this.where = name ? `your provider ${name}` : 'your hardware'; } estimate(request) { return this.call('provider_estimate', { name: this.provider, request: JSON.stringify({ ...request, provider: this.provider }) }); } // The estimate first; the person's yes before anything that is not free. async run(request, onProgress) { if (!this.provider) throw new Error(tr('hardware.choose_provider')); const req = { qasm: request.qasm, shots: request.shots, seed: request.seed, provider: this.provider }; const plan = await this.estimate(req); let confirm = false; if (needsConfirmation(plan)) { if (!(await this.ask(plan))) throw new Error('cancelled'); confirm = true; } this.stop = null; const v = await this.call('run_on_provider', { name: this.provider, request: JSON.stringify(req), confirmCost: confirm }); if (v.error) throw new Error(v.error); return this.follow(v, onProgress); } // A job until it ends, or until the person cancels it or leaves it waiting. async follow(job, onProgress) { this.current = job.id; let wait = this.first; let asked = 0; let failed = null; try { while (job.status === 'running') { onProgress?.(job, asked, failed); if (!this.stop) await this.nap(wait); if (this.stop === 'later') { this.watch(job.id); const e = new Error(tr('hardware.left_waiting', { id: job.id, provider: job.request?.provider })); e.later = true; throw e; } const cancel = this.stop === 'cancel'; try { job = await this.call('provider_job', { id: job.id, cancel }); failed = null; } catch (e) { // Nothing about the job changed, and asking again is safe. A // cancel that did not reach the provider says so, and the job, // still waiting, is followed in the background. if (cancel) { this.watch(job.id); throw e; } failed = e.message; } asked += 1; wait = Math.min(this.most, wait * 1.5); } } finally { this.current = null; this.stop = null; } if (job.status === 'done') return job; throw new Error(job.status === 'cancelled' ? 'cancelled' : job.error || `the job ${job.status}`); } nap(ms) { return new Promise((r) => { const t = setTimeout(r, ms); this.wake = () => { clearTimeout(t); r(); }; }); } // What the status line says while a job waits. progressText(job, asked, failed) { const s = tr('hardware.waiting', { id: job.id, provider: job.request?.provider, asked }); return failed ? `${s} ${tr('hardware.ask_failed', { why: failed })}` : s; } // Cancel the job at the provider; a job that finished first gives its result. cancel() { this.stop = 'cancel'; this.wake?.(); } // Leave the job at the provider and follow it in the background. later() { this.stop = 'later'; this.wake?.(); } // The jobs recorded on the person's providers, as each last stood. async jobs() { return (await this.call('provider_jobs', {})).jobs || []; } askAfter(id, cancel = false) { return this.call('provider_job', { id, cancel }); } // Every job still waiting, from an earlier window too, followed in the background. async resume() { for (const j of await this.jobs().catch(() => [])) if (j.status === 'running') this.watch(j.id); } // A job followed in the background until it ends: its result to // onFinished, or why it ended to onEnded. Asking spends nothing. watch(id) { if (this.watched.has(id)) return; this.watched.add(id); (async () => { let wait = this.first; for (;;) { await new Promise((r) => setTimeout(r, wait)); wait = Math.min(this.most, wait * 1.5); let job; try { job = await this.call('provider_job', { id, cancel: false }); } catch (e) { continue; } if (job.status === 'running') continue; this.watched.delete(id); if (job.status === 'done') this.onFinished?.(job); else this.onEnded?.(job); return; } })(); } methods() { return Promise.resolve({ methods: [] }); } } // A dialog that asks before a run that is not free: resolves true on "Run". export function askDialog(host) { return (plan) => new Promise((resolve) => { const yes = el('button', { class: 'qs-btn qs-primary', type: 'button', id: 'qs-hw-yes', text: tr('hardware.run_it') }); const no = el('button', { class: 'qs-btn', type: 'button', id: 'qs-hw-no', text: tr('hardware.cancel') }); const d = el('dialog', { class: 'qs-dialog', id: 'qs-hw-confirm', 'aria-labelledby': 'qs-hw-confirm-title' }, el('h2', { id: 'qs-hw-confirm-title', text: tr('hardware.not_free_title') }), ...confirmText(plan).map((t) => el('p', { text: t })), el('div', { class: 'qs-row' }, no, yes)); const end = (v) => { d.close(); d.remove(); resolve(v); }; yes.addEventListener('click', () => end(true)); no.addEventListener('click', () => end(false)); d.addEventListener('cancel', (e) => { e.preventDefault(); end(false); }); host.append(d); d.showModal(); no.focus(); }); } // A job in the list, in words: where it stands and when it was sent. export function jobLine(j, when = (ms) => new Date(ms).toLocaleString()) { const key = `hardware.state_${j.status}`; const state = tr(key) === key ? j.status : tr(key); return tr('hardware.job_line', { id: j.id, provider: j.provider, shots: j.shots, state, when: when(j.submitted_ms) }); } // The jobs sent to the person's providers from this computer: each one's // state as last recorded, a finished one shown in the Studio, and Cancel for // one still waiting. Opening the list asks no provider. export function jobsDialog(host, { engine, show, say }) { const list = el('ul', { class: 'qs-hw-jobs', id: 'qs-hw-jobs-list' }); const close = el('button', { class: 'qs-btn', type: 'button', text: tr('hardware.close') }); const d = el('dialog', { class: 'qs-dialog', id: 'qs-hw-jobs', 'aria-labelledby': 'qs-hw-jobs-title' }, el('h2', { id: 'qs-hw-jobs-title', text: tr('hardware.jobs') }), list, el('div', { class: 'qs-row' }, close)); const fill = async () => { let jobs = []; try { jobs = await engine.jobs(); } catch (e) { say(e.message); } list.replaceChildren(...(jobs.length ? jobs.map((j) => { const act = []; if (j.status === 'done') act.push(el('button', { class: 'qs-btn', type: 'button', text: tr('hardware.show'), onclick: async () => { try { show(await engine.askAfter(j.id)); end(); } catch (e) { say(e.message); } } })); if (j.status === 'running') act.push(el('button', { class: 'qs-btn', type: 'button', text: tr('hardware.cancel_job'), onclick: async () => { try { const v = await engine.askAfter(j.id, true); if (v.status === 'done') { show(v); end(); return; } say(tr('hardware.ended', { id: j.id, provider: j.provider, why: v.error || v.status })); fill(); } catch (e) { say(e.message); } } })); return el('li', {}, el('span', { text: jobLine(j) }), j.error ? el('span', { class: 'qs-note', text: ' ' + j.error }) : null, ...act); }) : [el('li', { class: 'qs-note', text: tr('hardware.jobs_empty') })])); }; const end = () => { d.close(); d.remove(); }; close.addEventListener('click', end); d.addEventListener('cancel', (e) => { e.preventDefault(); end(); }); host.append(d); d.showModal(); close.focus(); return fill(); } // The switch's segment and, on the desktop, the provider picker and the // list of jobs sent. `onPick` is told when the person picks this place to // run; `jobs` opens the list. export function hardwareSegment({ desktop, engine, onPick, say, jobs }) { const seg = el('button', { class: 'qs-seg', type: 'button', 'aria-pressed': 'false', 'data-where': 'hardware', id: 'qs-where-hardware', text: tr('hardware.segment'), title: desktop ? tr('hardware.segment_title') : NOTE }); const select = el('select', { class: 'qs-select', id: 'qs-provider', 'aria-label': tr('hardware.provider_label') }); const jobsBtn = el('button', { class: 'qs-btn', type: 'button', id: 'qs-hw-jobs-open', text: tr('hardware.jobs'), title: tr('hardware.jobs_title') }); const picker = el('span', { class: 'qs-hw', hidden: true }, select, jobsBtn); seg.addEventListener('click', async () => { if (!desktop) { say(NOTE); return; } try { const list = await engine.providers(); if (!list.length) { say(tr('hardware.no_providers')); return; } select.replaceChildren(...list.map((p) => el('option', { value: p.name, text: `${p.name} (${p.kind})` }))); picker.hidden = false; engine.select(select.value); onPick(); } catch (e) { say(e.message); } }); select.addEventListener('change', () => engine.select(select.value)); jobsBtn.addEventListener('click', () => jobs?.()); return { seg, picker }; }