@@ -27,6 +27,8 @@ import { fieldOverlay } from './volume-overlay';
2727// with the CLI's and the MCP adapter's semantics. Before this, the URL and the refusal
2828// shape were rebuilt at five call sites and the sixth would have differed.
2929import { DiracClient , DiracError , fetchField as sdkFetchField } from '../../../app/services/dirac-client' ;
30+ import { computationRunFromEnvelope , failedComputationRun , observedComputationRun ,
31+ renderComputationRun , type ComputationRunRecord } from '../../../app/services/computation-run' ;
3032
3133// A renderer that fails silently is indistinguishable from one that works but
3234// has nothing to draw. The handle costs nothing and every harness can read it.
@@ -303,6 +305,7 @@ export type FieldView = {
303305 meta : Record < string , any > | null ;
304306 digestVerified ?: boolean ;
305307 digestUnverifiedReason ?: string ;
308+ run ?: ComputationRunRecord ;
306309} ;
307310
308311let plugin : PluginContext | null = null ;
@@ -380,6 +383,12 @@ function viewFromBaked(m: any, kind: FieldKind): FieldView {
380383 digestUnverifiedReason : 'this field came from the pre-baked static assets, which '
381384 + 'carry no digest — it is a fallback for an offline backend, not a computed '
382385 + 'result' ,
386+ run : observedComputationRun ( 'precomputed.field' , {
387+ executor : 'static' , methodId : String ( meta . method || meta . kind || kind ) ,
388+ version : meta . method_version ?? null ,
389+ provenance : { structure_id : structureId , baked : true } ,
390+ note : 'Static fallback; no durable Job and no artifact digest are available.' ,
391+ } ) ,
383392 } ;
384393}
385394const pendingFetch = new Map < string , Promise < { cube : string , view : FieldView } | null > > ( ) ;
@@ -433,7 +442,8 @@ function molfileHeavyAtoms(mf: string): number {
433442 * two demand opposite next moves.
434443 */
435444class FieldRefusal extends Error {
436- constructor ( message : string , readonly reason : 'budget' | 'unsupported' | 'internal' | 'network' ) {
445+ constructor ( message : string ,
446+ readonly reason : 'budget' | 'unsupported' | 'internal' | 'network' | 'cancelled' ) {
437447 super ( message ) ;
438448 }
439449}
@@ -451,6 +461,8 @@ const clientTimeoutMs = (budget: number) => (budget * 2 + 20) * 1000;
451461
452462/** The request the user can cancel — one at a time, by construction. */
453463let inFlight : AbortController | null = null ;
464+ let inFlightJobId : string | null = null ;
465+ let inFlightCommand : 'structure.field.compute' | 'fields.region.mep' | null = null ;
454466/**
455467 * Whether the compute backend answered its health check. On the deployed
456468 * static site it never will, and that is the normal case there rather than an
@@ -466,7 +478,7 @@ export function setFieldStructureId(id: string | null) { structureId = id; }
466478/** Fetch one field into the browser cache (deduplicated). Throws FieldRefusal;
467479 * returns null if the focused ligand changed while in flight. */
468480async function fetchField ( kind : FieldKind , store = false ,
469- budget = QM_BUDGET_SECONDS ) : Promise < { cube : string , view : FieldView } | null > {
481+ budget = QM_BUDGET_SECONDS , reportActivity = false ) : Promise < { cube : string , view : FieldView } | null > {
470482 const key = cacheKey ( kind ) ;
471483 if ( ! store ) {
472484 const hit = cubeCache . get ( key ) ;
@@ -499,12 +511,24 @@ async function fetchField(kind: FieldKind, store = false,
499511 const basis = Kinds [ kind ] . quantum ? currentBasis ( ) : undefined ;
500512 const controller = new AbortController ( ) ;
501513 inFlight = controller ;
502- const timer = setTimeout ( ( ) => controller . abort ( ) , clientTimeoutMs ( budget ) ) ;
514+ inFlightCommand = 'structure.field.compute' ;
515+ let timedOut = false ;
516+ const timer = setTimeout ( ( ) => {
517+ timedOut = true ;
518+ controller . abort ( ) ;
519+ } , clientTimeoutMs ( budget ) ) ;
503520 const p = ( async ( ) => {
504521 try {
505522 const got = await sdkFetchField ( dirac , kind , {
506523 molfile : requestMolfile ! , basis, maxSeconds : budget ,
507524 signal : controller . signal ,
525+ onAccepted : accepted => {
526+ inFlightJobId = String ( accepted . meta ?. job_id || '' ) || null ;
527+ if ( reportActivity ) {
528+ renderComputationRun ( 'field-run-record' ,
529+ computationRunFromEnvelope ( 'structure.field.compute' , accepted , 'queued' ) ) ;
530+ }
531+ } ,
508532 } ) ;
509533 if ( molfile !== requestMolfile ) return null ;
510534 // The digest check is REPORTED, not assumed. On this origin (plain http on a
@@ -515,6 +539,14 @@ async function fetchField(kind: FieldKind, store = false,
515539 data : got . data , warnings : got . warnings , meta : got . envelope . meta ?? null ,
516540 digestVerified : got . digestVerified ,
517541 digestUnverifiedReason : got . digestUnverifiedReason ,
542+ run : {
543+ ...computationRunFromEnvelope ( 'structure.field.compute' , got . envelope ) ,
544+ provenance : {
545+ ...( got . envelope . meta ?. provenance || { } ) ,
546+ artifact_digest : got . digestVerified ? 'verified'
547+ : got . digestUnverifiedReason || 'not verified' ,
548+ } ,
549+ } ,
518550 } ;
519551 const entry = { cube : got . cube , view } ;
520552 cubeCache . set ( key , entry ) ;
@@ -539,6 +571,9 @@ async function fetchField(kind: FieldKind, store = false,
539571 bucket ) ;
540572 }
541573 if ( controller . signal . aborted ) {
574+ if ( ! timedOut ) {
575+ throw new FieldRefusal ( 'Cancelled by user.' , 'cancelled' ) ;
576+ }
542577 throw new FieldRefusal (
543578 `cancelled after ${ budget * 2 + 20 } s — the daemon should have `
544579 + `stopped itself at ${ budget } s, so it is over its own bound` ,
@@ -558,7 +593,11 @@ async function fetchField(kind: FieldKind, store = false,
558593 e instanceof Error ? e . message : String ( e ) , 'network' ) ;
559594 } finally {
560595 clearTimeout ( timer ) ;
561- if ( inFlight === controller ) inFlight = null ;
596+ if ( inFlight === controller ) {
597+ inFlight = null ;
598+ inFlightJobId = null ;
599+ inFlightCommand = null ;
600+ }
562601 pendingFetch . delete ( key ) ;
563602 }
564603 } ) ( ) ;
@@ -569,8 +608,21 @@ async function fetchField(kind: FieldKind, store = false,
569608/** Abandon whatever is in flight. Used by the Cancel control and by any change
570609 * that makes the answer worthless before it arrives. */
571610function abortInFlight ( ) {
611+ const jobId = inFlightJobId ;
612+ const command = inFlightCommand ;
613+ if ( jobId ) {
614+ void dirac . execute ( 'job.cancel' , { job_ref : { kind : 'job' , id : jobId } } ) ;
615+ }
616+ if ( command ) {
617+ renderComputationRun ( 'field-run-record' ,
618+ failedComputationRun ( command , jobId
619+ ? `Cancellation requested for durable Job ${ jobId } `
620+ : 'Cancelled before a durable Job was accepted.' , 'cancelled' ) ) ;
621+ }
572622 inFlight ?. abort ( ) ;
573623 inFlight = null ;
624+ inFlightJobId = null ;
625+ inFlightCommand = null ;
574626 // A promise from the OLD molecule left in this map is not merely stale: a
575627 // request for the NEW molecule with the same kind|basis key joins it,
576628 // reads `molfile !== requestMolfile`, resolves null, and the panel reports
@@ -605,7 +657,7 @@ async function prefetchAll() {
605657 // deleted it and substituted a falsehood.
606658 const failed : { label : string , why : string } [ ] = [ ] ;
607659 for ( const kind of kinds ) {
608- if ( molfile !== startedFor ) return ; // ligand changed mid-prefetch
660+ if ( molfile !== startedFor ) return ; // ligand changed mid-prefetch
609661 setPrefetchNote ( `Precomputing fields ${ done } /${ kinds . length } — ${ Kinds [ kind ] . label } …` ) ;
610662 try {
611663 await fetchField ( kind ) ;
@@ -878,11 +930,16 @@ export function buildMetaDisplay(view: FieldView | null): { rows: MetaRow[], cav
878930function renderMeta ( view : FieldView | null ) {
879931 const el = byId ( 'field-meta' ) ;
880932 if ( ! el ) return ;
881- if ( ! view ) { el . innerHTML = '' ; return ; }
933+ if ( ! view ) {
934+ el . innerHTML = '' ;
935+ renderComputationRun ( 'field-run-record' , null ) ;
936+ return ;
937+ }
882938 const { rows, caveats } = buildMetaDisplay ( view ) ;
883939 el . innerHTML = rows . map ( ( { label, value } ) =>
884940 `<div class="field-meta-row"><span>${ label } </span><span>${ value } </span></div>` ) . join ( '' )
885941 + caveats . map ( c => `<div class="field-caveat">${ escapeHtml ( c ) } </div>` ) . join ( '' ) ;
942+ renderComputationRun ( 'field-run-record' , view . run ?? null ) ;
886943}
887944
888945function reprParams ( kind : FieldKind , sign : 1 | - 1 , shell : number ) {
@@ -1058,6 +1115,14 @@ async function requestPocketField() {
10581115 setButtonsEnabled ( ) ;
10591116 setStatus ( `Pocket field — ${ built . sources . length } shell atoms as source, `
10601117 + `sampled in the ligand's box…` , 'busy' ) ;
1118+ renderComputationRun ( 'field-run-record' , {
1119+ command : 'fields.region.mep' , phase : 'submitting' , executor : 'service' ,
1120+ artifacts : [ ] , provenance : { source_atoms : built . sources . length } ,
1121+ note : 'Pocket sources define the field; the ligand defines only the sampling frame.' ,
1122+ } ) ;
1123+ const controller = new AbortController ( ) ;
1124+ inFlight = controller ;
1125+ inFlightCommand = 'fields.region.mep' ;
10611126 try {
10621127 // GROW THE FRAME UNTIL THE CONTOUR CLOSES. The ligand path does this
10631128 // in the backend; here it cannot, because the frame belongs to the
@@ -1086,7 +1151,7 @@ async function requestPocketField() {
10861151 return { ...rest , xyz : [ x , y , z ] } ;
10871152 } ) ,
10881153 frame : grown . frame ,
1089- } , { inlineMax : 0 } ) ;
1154+ } , { inlineMax : 0 , signal : controller . signal } ) ;
10901155 if ( ! env . ok ) break ;
10911156 // The box-growing loop reads the CONTRACT-DECLARED flag now, not a key that
10921157 // happened to be in an ungoverned dict.
@@ -1096,6 +1161,9 @@ async function requestPocketField() {
10961161 const e = env ?. error ;
10971162 setStatus ( e ? `${ e . user_message || e . message } ${ e . caller_action ? ' — ' + e . caller_action : '' } `
10981163 : 'the pocket field could not be computed' , 'error' ) ;
1164+ renderComputationRun ( 'field-run-record' , env
1165+ ? computationRunFromEnvelope ( 'fields.region.mep' , env )
1166+ : failedComputationRun ( 'fields.region.mep' , 'No response envelope' ) ) ;
10991167 return ;
11001168 }
11011169 const ref = ( env . artifacts || [ ] ) [ 0 ] ;
@@ -1110,6 +1178,14 @@ async function requestPocketField() {
11101178 meta : env . meta ?? null ,
11111179 digestVerified : got . verified ,
11121180 digestUnverifiedReason : got . unverifiedReason ,
1181+ run : {
1182+ ...computationRunFromEnvelope ( 'fields.region.mep' , env ) ,
1183+ provenance : {
1184+ ...( env . meta ?. provenance || { } ) ,
1185+ artifact_digest : got . verified ? 'verified'
1186+ : got . unverifiedReason || 'not verified' ,
1187+ } ,
1188+ } ,
11131189 } ;
11141190 await renderCube ( got . text ( ) , 'pocket_mep' , pview ) ;
11151191 renderMeta ( pview ) ;
@@ -1134,8 +1210,17 @@ async function requestPocketField() {
11341210 setStatus ( `Pocket map from ${ sourceNote } , net charge ${ model . net_charge } · `
11351211 + `${ chargeModelNote } · frame +${ framePad } Å around the ligand.` , 'ok' ) ;
11361212 } catch ( e ) {
1213+ if ( controller . signal . aborted ) {
1214+ setStatus ( 'Pocket field cancelled.' , 'idle' ) ;
1215+ return ;
1216+ }
11371217 setStatus ( `Backend unreachable — ${ e instanceof Error ? e . message : String ( e ) } ` , 'error' ) ;
1218+ renderComputationRun ( 'field-run-record' , failedComputationRun ( 'fields.region.mep' , e ) ) ;
11381219 } finally {
1220+ if ( inFlight === controller ) {
1221+ inFlight = null ;
1222+ inFlightCommand = null ;
1223+ }
11391224 busy = false ;
11401225 setButtonsEnabled ( ) ;
11411226 }
@@ -1176,8 +1261,12 @@ async function requestField(kind: FieldKind, budget = budgetFor(kind)) {
11761261 ? `Solving ${ spec . label } — pyscf SCF on ${ ligandLabel ?? 'ligand' } … `
11771262 + `(gives up at ${ budget } s · Cancel to stop now)`
11781263 : `Computing ${ spec . label } …` , 'busy' ) ;
1264+ renderComputationRun ( 'field-run-record' , {
1265+ command : 'structure.field.compute' , phase : 'submitting' , executor : 'service' ,
1266+ artifacts : [ ] , provenance : { field_kind : kind , basis : spec . quantum ? currentBasis ( ) : 'not applicable' } ,
1267+ } ) ;
11791268 try {
1180- const entry = await fetchField ( kind , false , budget ) ;
1269+ const entry = await fetchField ( kind , false , budget , true ) ;
11811270 if ( entry === null || molfile !== requestMolfile ) {
11821271 setStatus ( 'Ligand changed while computing — stale field discarded.' , 'idle' ) ;
11831272 return ;
@@ -1192,6 +1281,16 @@ async function requestField(kind: FieldKind, budget = budgetFor(kind)) {
11921281 const reason = e instanceof FieldRefusal ? e . reason : 'internal' ;
11931282 const msg = e instanceof Error ? e . message : String ( e ) ;
11941283 renderMeta ( null ) ;
1284+ if ( reason === 'cancelled' ) {
1285+ renderComputationRun ( 'field-run-record' ,
1286+ failedComputationRun ( 'structure.field.compute' , msg , 'cancelled' ) ) ;
1287+ setStatus ( msg , 'idle' ) ;
1288+ return ;
1289+ }
1290+ const code = reason === 'budget' ? 'BUDGET'
1291+ : reason === 'unsupported' ? 'UNSUPPORTED' : 'INTERNAL' ;
1292+ renderComputationRun ( 'field-run-record' , computationRunFromEnvelope (
1293+ 'structure.field.compute' , { ok : false , error : { code, message : msg } } ) ) ;
11951294 if ( reason === 'network' ) {
11961295 setStatus ( `Backend unreachable — start it with: `
11971296 + `backend/env/bin/python backend/field_server.py (${ msg } )` , 'error' ) ;
@@ -1299,8 +1398,8 @@ export function initFieldWellsPanel(p: PluginContext) {
12991398 abortInFlight ( ) ;
13001399 busy = false ;
13011400 setButtonsEnabled ( ) ;
1302- setStatus ( 'Cancelled . The daemon stops at its own deadline ; '
1303- + 'nothing is left running .' , 'idle' ) ;
1401+ setStatus ( 'Cancellation requested . The durable Job records the request ; '
1402+ + 'native work stops at cooperative checkpoints .' , 'idle' ) ;
13041403 } ) ;
13051404 // Changing basis invalidates the quantum entries only; classical fields
13061405 // have no basis. Cheapest correct move: refetch on demand.
@@ -1340,7 +1439,7 @@ export function initFieldWellsPanel(p: PluginContext) {
13401439export function invalidateFieldsForGeometry ( nextMolfile : string | null ) {
13411440 molfile = nextMolfile ;
13421441 abortInFlight ( ) ;
1343- cubeCache . clear ( ) ; // every entry was keyed to the old coordinates
1442+ cubeCache . clear ( ) ; // every entry was keyed to the old coordinates
13441443
13451444 if ( ! activeKind ) return ;
13461445 const spec = Kinds [ activeKind ] ;
0 commit comments