Skip to content

Commit e9f601b

Browse files
committed
feat(backend): view.json snapshots and tier-1 activation ordering
Write runs/{id}/view.json at finalize_run and serve GET /api/runs/{id} snapshot-first (critic + todos from session state). Record lab_response before Census activation so approved rows are visible to reverse-ETL; patch snapshot on experiment approval. Immutable Cache-Control on pinned artifact fragments.
1 parent ac04e39 commit e9f601b

4 files changed

Lines changed: 424 additions & 101 deletions

File tree

backend/dry_lab/callbacks.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import uuid
2323

2424
from . import run_store
25+
from . import view_builder
2526
from .config import settings
2627
from .dataset_context import pipeline_fallback_summary
2728
from .sub_agents.investigator.tools import shutdown_kernel
@@ -229,6 +230,13 @@ async def finalize_run(callback_context):
229230
pass # already flushed
230231
except Exception as e:
231232
logger.warning("finish_run failed (continuing): %s", e)
233+
234+
# 3b) Persist a read-optimized view snapshot beside the provenance pins (fast GET /api/runs/{id}).
235+
try:
236+
view_builder.write_snapshot(run_id, view_builder.build_run_view_from_state(state, status=status))
237+
except Exception as e:
238+
logger.warning("write_snapshot failed for %s (continuing): %s", run_id, e)
239+
232240
# 4) CROSS-RUN MEMORY (Phase D — gated + OFF the critical path). If a real Vertex AI Memory Bank is wired
233241
# (settings.memory_enabled, i.e. MEMORY_SERVICE_URI set), persist this session so a RETURNING user's
234242
# cohort / prior findings / preferences can be recalled on a later run. UNSET (the default) -> ADK's

backend/dry_lab/server.py

Lines changed: 29 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
from google.adk.cli.utils.service_factory import create_artifact_service_from_options
3131

3232
from . import run_store
33+
from . import view_builder
3334
from .config import settings
34-
from .dataset_context import data_status_dict
3535
from .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-
274282
def _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

309286
def _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

Comments
 (0)