Skip to content

Commit 1a5aa62

Browse files
Janardan S Kaviaclaude
authored andcommitted
feat(workflow-api): persist sync run outputs to Job.result
A sync run already creates a Job row (to support HITL suspend + run_id-keyed vertex builds) and reaches COMPLETED, but never wrote Job.result — so a later GET status on its job_id could not return the outputs the request returned inline. Persist the terminal outputs in the same list-of-OutputEvent shape the background runner writes, so the status read is protocol-uniform across sync and background. Best-effort: the caller already holds the response inline, so a result-cache write failure is logged and never fails the run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 46fee33 commit 1a5aa62

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

src/backend/base/langflow/api/v2/workflow_execution.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,25 @@ async def execute_sync_workflow_with_timeout(
452452
raise WorkflowTimeoutError from e
453453

454454

455+
async def _persist_sync_result(job_service, job_id: UUID, workflow_response, flow_id) -> None:
456+
"""Best-effort cache of a completed sync run's outputs into ``Job.result``.
457+
458+
Stores the same ``{component_id, ...ComponentOutput}`` list shape the background
459+
runner writes, so a later GET status on this sync job_id rebuilds an identical
460+
response via ``workflow_response_from_output_events``. The caller already holds
461+
the response inline, so a persistence failure must never fail the run — it is
462+
logged and swallowed.
463+
"""
464+
try:
465+
output_events = [
466+
{"component_id": component_id, **output.model_dump(mode="json")}
467+
for component_id, output in (workflow_response.outputs or {}).items()
468+
]
469+
await job_service.set_result(job_id, {"status": "completed", "outputs": output_events})
470+
except (RuntimeError, ValueError, OSError):
471+
await logger.awarning("Sync result persistence failed for flow %s", flow_id, exc_info=True)
472+
473+
455474
async def execute_sync_workflow(
456475
parsed: ParsedWorkflowRun,
457476
flow: FlowRead,
@@ -576,7 +595,7 @@ async def execute_sync_workflow(
576595
# Build RunResponse
577596
run_response = RunResponse(outputs=task_result, session_id=execution_session_id)
578597
# Convert to WorkflowExecutionResponse
579-
return run_response_to_workflow_response(
598+
workflow_response = run_response_to_workflow_response(
580599
run_response=run_response,
581600
flow_id=parsed.flow_id,
582601
job_id=str(job_id),
@@ -585,6 +604,11 @@ async def execute_sync_workflow(
585604
effective_globals=request_variables,
586605
selected_ids=parsed.output_ids,
587606
)
607+
# Persist the terminal outputs to Job.result so a later GET status on this
608+
# sync job_id returns the same outputs the background path stores (same
609+
# list-of-OutputEvent shape, so the status read is protocol-uniform).
610+
await _persist_sync_result(job_service, job_id, workflow_response, flow.id)
611+
return workflow_response # noqa: TRY300 — keep response-building under the broad except below
588612

589613
except GraphPausedException as exc:
590614
# HITL: a pausing node suspended the run for human input. The checkpoint is already

src/backend/tests/unit/api/v2/test_workflow_background.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,38 @@ async def test_finalize_job_status_still_writes_terminal_for_running_job():
341341
await wb._finalize_job_status(uuid4(), JobStatus.COMPLETED)
342342

343343
fake_service.update_job_status.assert_awaited_once()
344+
345+
346+
async def test_sync_run_persists_job_result(client, created_api_key, bg_flow):
347+
"""A sync run persists its outputs to Job.result so a later GET status returns them.
348+
349+
Sync already creates a Job row (to support HITL suspend + run_id-keyed builds);
350+
this asserts the completed run now also writes Job.result in the same
351+
list-of-OutputEvent shape the background path stores, so the status read is
352+
protocol-uniform across sync and background.
353+
"""
354+
from uuid import UUID
355+
356+
from langflow.services.database.models.jobs.model import Job
357+
358+
body = {"flow_id": bg_flow, "mode": "sync", "input_value": "hi"}
359+
resp = await client.post("api/v2/workflows", json=body, headers=_headers(created_api_key))
360+
assert resp.status_code == 200, resp.text
361+
data = resp.json()
362+
assert data["status"] == "completed", data
363+
assert data["outputs"], f"sync response carried no outputs: {data}"
364+
job_id = data["job_id"]
365+
366+
# Job.result persisted (same {status, outputs:[...]} blob the runner writes).
367+
async with session_scope() as session:
368+
row = await session.get(Job, UUID(job_id))
369+
assert row is not None
370+
assert isinstance(row.result, dict), f"Job.result not a dict: {row.result!r}"
371+
assert row.result.get("outputs"), f"Job.result has no outputs: {row.result}"
372+
373+
# GET status reconstructs the same outputs from Job.result.
374+
status = await client.get("api/v2/workflows", params={"job_id": job_id}, headers=_headers(created_api_key))
375+
assert status.status_code == 200, status.text
376+
sbody = status.json()
377+
assert sbody["status"] == "completed"
378+
assert sbody["outputs"], f"GET status carried no outputs for sync job: {sbody}"

0 commit comments

Comments
 (0)