open-standards/wai/quantum-studio/src/hardware.js
Transaction Science 2257b320fc Quantum Studio: a job on your own hardware outlives the window
A run on one of the person's providers waited inside the desktop app for up
to two hours, holding the Studio, and then cancelled the job, giving up its
place in a device's queue. The app now writes the job down before it sends
it, with the command line's records of jobs on a provider (#581), and asks
after it by its id.

- wai-quantum-desktop: run_on_provider answers within ten seconds, done or
  still running with its id; provider_job asks after a job once or cancels
  it; provider_jobs lists the jobs sent, asking no provider.
- Quantum Studio: while a job waits, Cancel cancels it at the provider and
  "Wait in the background" leaves it there and frees the Studio. A job left
  waiting, from this window or an earlier one, is followed while the app is
  open, and its result goes to the history when it finishes. Your jobs lists
  every job sent. A check that fails during the wait does not end it.
- The tests: the app's queued job answered by its id, sealed with the app's
  key, the same answer after, and cancelled only when asked; the page's
  wait, cancel, cancel-after-finish, failed check, background and reopened
  window. The studio workflow names the new app test on every leg.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:19:02 -05:00

247 lines
12 KiB
JavaScript

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