3030from google .adk .cli .utils .service_factory import create_artifact_service_from_options
3131
3232from . import run_store
33+ from . import view_builder
3334from .config import settings
34- from .dataset_context import data_status_dict
3535from .sub_agents .pipeline .tools import activate_experiment , activation_configured
3636
3737# agents_dir = the directory that CONTAINS the `dry_lab` package (so ADK discovers dry_lab/agent.py:root_agent).
@@ -173,8 +173,17 @@ async def emit_experiment(req: dict):
173173 decision = req .get ("decision" ) or "approved"
174174
175175 if activation_configured ():
176- # TIER 1 — real Fivetran Activation (reverse-ETL). Triggers the Census sync; the approved request travels
177- # from BigQuery run_status to the destination. Returns an honest response (channel/sync_run_id/error).
176+ # TIER 1 — Census reverse-ETL reads run_status WHERE lab_decision='approved'. Record the decision BEFORE
177+ # triggering the sync so the row is visible when the Activation runs.
178+ if run_id :
179+ try :
180+ run_store .record_lab_response (
181+ run_id , decision = decision ,
182+ response = {"channel" : "fivetran_activation" , "status" : "triggering" , "mocked" : False },
183+ )
184+ except Exception as e :
185+ logging .getLogger ("dry_lab.server" ).error (
186+ "record_lab_response (pre-activation) failed for run %s: %s" , run_id , e )
178187 lab_response = activate_experiment (req )
179188 elif settings .lab_endpoint_url :
180189 # TIER 2 — real webhook.
@@ -196,6 +205,11 @@ async def emit_experiment(req: dict):
196205 except Exception as e :
197206 logging .getLogger ("dry_lab.server" ).error (
198207 "record_lab_response append failed for run %s: %s" , run_id , e )
208+ try :
209+ view_builder .patch_activation (run_id , proposal = req , lab_response = lab_response )
210+ except Exception as e :
211+ logging .getLogger ("dry_lab.server" ).warning (
212+ "view snapshot activation patch failed for run %s: %s" , run_id , e )
199213 return {"status" : "sent" , "decision" : decision , "lab_response" : lab_response }
200214
201215
@@ -265,45 +279,8 @@ async def get_shared(sid: str):
265279 os .environ .get ("DRYLAB_PROVENANCE_BUCKET" , "" ),
266280 os .environ .get ("ARTIFACT_BUCKET" , "" )) if b }
267281
268- # The eval harness prepends an internal "[Use skill: …]" routing directive to a run's goal — machinery, never a
269- # user-facing title. Strip it server-side so both the read-only run view and the runs list show the real goal.
270- # (Mirror of cleanGoal() in frontend/src/components/RunsList.tsx.)
271- _USE_SKILL_RE = re .compile (r"^\s*\[Use skill:[^\]]*\]\s*" , re .I )
272-
273-
274282def _clean_goal (goal : str | None ) -> str :
275- return _USE_SKILL_RE .sub ("" , goal or "" ).strip ()
276-
277-
278- def _pinned_results (evidence : list [dict ]) -> tuple [list [dict ], dict ]:
279- """From computed evidence rows (each pinned to gs://bucket/runs/<run>/<file>#<gen>) build read-only
280- ResultFile entries served via /pinned, plus a {filename: pinned_uri} map (latest wins)."""
281- import mimetypes # noqa: F401 (kept local; used implicitly by the frontend kind inference)
282- by_name : dict [str , str ] = {}
283- for e in evidence :
284- if e .get ("kind" ) != "computed" :
285- continue
286- uri = e .get ("result_uri" ) or ""
287- if not uri .startswith ("gs://" ):
288- continue
289- base = uri .split ("#" , 1 )[0 ].split ("/" )[- 1 ]
290- by_name [base ] = uri # latest row for a name wins (re-runs overwrite within a run)
291- def _kind (n : str ) -> str :
292- low = n .lower ()
293- if low .endswith ((".csv" , ".tsv" )):
294- return "de_table"
295- if low .endswith ((".png" , ".jpg" , ".jpeg" , ".svg" )):
296- return "figure"
297- if low .endswith ((".md" , ".pdf" )):
298- return "report"
299- if low .endswith (".ipynb" ):
300- return "notebook"
301- return "de_table"
302- from urllib .parse import quote
303- # The pinned uri carries a '#<generation>' fragment + ':' — encode it so the browser keeps it in the query.
304- results = [{"name" : n , "uri" : f"/api/pinned?uri={ quote (n_uri , safe = '' )} " , "kind" : _kind (n )}
305- for n , n_uri in by_name .items ()]
306- return results , by_name
283+ return view_builder .clean_goal (goal )
307284
308285
309286def _fetch_pinned_bytes (pinned_uri : str ) -> bytes | None :
@@ -362,65 +339,12 @@ async def get_run_view(run_id: str, request: Request):
362339 run = run_store .get_run (run_id , user_id = _request_uid (request ) or None )
363340 if not run :
364341 raise HTTPException (status_code = 404 , detail = f"run '{ run_id } ' not found" )
365- evidence = run .get ("evidence" ) or []
366- results , by_name = _pinned_results (evidence )
367-
368- # report.md text (pinned in save_report) -> the report tab; absent is an honest empty state.
369- report = None
370- if "report.md" in by_name :
371- data = _fetch_pinned_bytes (by_name ["report.md" ])
372- if data :
373- report = {"markdown" : data .decode ("utf-8" , errors = "replace" )}
374-
375- status = run .get ("status" ) or ""
376- summaries = {r .get ("stage" ): r .get ("summary" ) for r in (run .get ("stage_summaries" ) or []) if isinstance (r , dict )}
377- proposal = run .get ("experiment_request" )
378- _LOOP = [("plan" , "Plan & approve" , "@Planner" ), ("pipeline" , "Sync & QC data (Fivetran)" , "@Pipeline" ),
379- ("investigator" , "Analyze & write report" , "@Investigator" ),
380- ("critic" , "Reliability review" , "@Critic" ), ("propose" , "Propose next experiment" , "@Proposer" )]
381- passed = status in ("completed" , "proposed" ) # finish_run only reaches these when the critic passed
382- todos = []
383- for stage , label , agent in _LOOP :
384- if stage == "plan" :
385- done , note = True , "Plan approved"
386- elif stage == "propose" :
387- done , note = bool (proposal ), None
388- elif stage == "critic" :
389- done , note = passed , (f"Passed (recorded run status: { status } )" if passed else None )
390- else :
391- done , note = (stage in summaries ), summaries .get (stage )
392- todos .append ({"id" : stage , "label" : label , "agent" : agent ,
393- "status" : "done" if done else "pending" , "result_summary" : note })
394-
395- activation = None
396- if proposal and run .get ("lab_response" ) is not None :
397- activation = {"payload" : proposal , "lab_response" : run .get ("lab_response" ), "status" : "sent" }
398-
399- view = {
400- "research_goal" : _clean_goal (run .get ("goal" )),
401- "messages" : [], # the frontend derives a read-only thread from the grounded fields below
402- "todos" : todos ,
403- "traces" : [],
404- "results" : results ,
405- "evidence" : [{"id" : e .get ("evidence_id" ), "kind" : e .get ("kind" ), "claim" : e .get ("claim" ),
406- "result_uri" : e .get ("result_uri" ), "source_checksum" : e .get ("source_checksum" ),
407- "code_cell_ref" : e .get ("code_cell_ref" ), "citation" : e .get ("citation" )} for e in evidence ],
408- # Honest per-dataset label: route through dataset_context using the run's RECORDED skill (run_store
409- # persists + returns it) so a trials/survival/dose-response run on /runs shows ITS dataset, not the
410- # GSE206285 default that was previously hardcoded for every reconstructed run.
411- "data_status" : data_status_dict (run .get ("skill" ), run .get ("goal" ) or "" ),
412- "report" : report ,
413- "critic" : None , # the verdict isn't persisted in the index; don't fabricate one for a past run
414- "critic_iterations" : [],
415- "proposed_experiment" : proposal ,
416- "activation" : activation ,
417- "awaiting_approval" : None ,
418- # skill_tier is NOT in the thin runs index (it's a live/session signal), so a reconstructed past run leaves
419- # it absent → no unverified banner here. Honest by omission, not fabrication (same rule as critic above):
420- # we never assert "vetted" for a run whose tier we didn't durably record. A SHARED /r/:id view DOES carry
421- # skill_tier because it snapshots the live DryLabView (which had it set by save_report).
422- }
423- return view
342+
343+ snap = view_builder .read_snapshot (run_id )
344+ if snap :
345+ return view_builder .enrich_snapshot (snap , run , fetch_report = _fetch_pinned_bytes )
346+
347+ return view_builder .build_run_view_from_record (run , fetch_report = _fetch_pinned_bytes )
424348
425349
426350@app .get ("/api/pinned" )
@@ -434,7 +358,11 @@ async def get_pinned(uri: str):
434358 import mimetypes
435359 name = uri .split ("#" , 1 )[0 ].split ("/" )[- 1 ]
436360 mime , _ = mimetypes .guess_type (name )
437- return Response (content = data , media_type = mime or "application/octet-stream" )
361+ return Response (
362+ content = data ,
363+ media_type = mime or "application/octet-stream" ,
364+ headers = {"Cache-Control" : "public, max-age=31536000, immutable" },
365+ )
438366
439367
440368# --- BRING YOUR OWN SKILL (PHASE B; additive, governed). The agent's vetted-skill registry is already data-driven
0 commit comments