Skip to content

Commit 646b0f2

Browse files
Janardan S Kaviadkaushik94
authored andcommitted
feat(workflow-api): make Job.result the default GET-status source with session_id
Flip the v2 workflow GET-status construction so the durable Job.result blob is the primary output source and vertex_build reconstruction is the fallback. Echo session_id (from job_metadata request, falling back to flow id) on the response so completed background runs return the same shape as sync. Populate Job.result for AG-UI runs via protocol-neutral off-wire capture: a synthesized WORKFLOW_OUTPUT_CAPTURE_EVENT frame built from the raw end_vertex before adapter.translate, captured in-memory only (never appended or published, so it cannot leak to /events). This makes AG-UI runs carry full outputs even though the wire protocol emits no output event. Add LANGFLOW_JOB_EVENTS_STORAGE_ENABLED to gate job_events persistence. When off, the runner uses a local seq fallback so live subscribers still work while per-milestone DB writes and reattach/replay are skipped; Job.result and GET-status output are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> fix(workflow-api): preserve pre-pause HITL outputs; drop job-events storage flag A background run that produced a terminal output on one branch, then paused for human input on another, lost that output: the resumed pass starts a fresh capture list and the output loop skips re-emitting checkpoint-restored vertices, so finalize only had the resumed pass's captures. Fix: PauseRequested carries the pre-pause output_events out of _drive; _suspend stashes them (deduped) in job_metadata.pre_pause_outputs; a resumed _drive pre-seeds its capture list from that stash; set_result dedups by component_id (first position, latest value) so a re-emitted vertex overwrites its stale entry and branch order is preserved. Also remove the job_events_storage_enabled setting — the durable event log is now always persisted (removes the event-storage-off path that dropped live HITL requests). Drops the field from EXPECTED_FIELDS and the now-impossible off-mode test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> fix: harden background workflow result handling 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> Fixed exception handling upon persistence failure but execution success. now persistence issue won't block users from getting the output if the workflow ran successfully.
1 parent f953218 commit 646b0f2

13 files changed

Lines changed: 901 additions & 62 deletions

File tree

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

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,10 @@ async def _source(*, job_id=None, resume=None, **_kwargs):
458458
# job_id) and fires the memory-base hook below with that id, so the build pipeline
459459
# must not mint its own run_id-keyed WORKFLOW row + hook (it would double both).
460460
track_job_status=False,
461+
# Emit the off-wire terminal-output capture the runner records into
462+
# ``Job.result`` — protocol-neutral, so agui-protocol runs get a
463+
# populated GET-status result too (not just langflow).
464+
emit_output_capture=True,
461465
):
462466
if terminal_error_type is not None and event_type == terminal_error_type:
463467
errored = True
@@ -644,29 +648,64 @@ async def get_workflow_status(
644648
folder_id=getattr(flow, "folder_id", None),
645649
)
646650

647-
# Reconstruct response from vertex_build table (sync path persists
648-
# those keyed by job_id). Background runs do not write vertex_builds
649-
# keyed by job_id, so reconstruction finds nothing and raises
650-
# ValueError — fall back to the durable Job.result the runner wrote
651-
# so a completed background run reports completed instead of 500ing.
651+
# The session the run executed under, resolved exactly as the runner's
652+
# frame source did (``parsed.session_id or str(flow.id)``): the submit
653+
# request is persisted on ``job_metadata["request"]``, so a completed
654+
# background GET echoes the same chat/memory thread sync returns. The
655+
# terminal output's ``content`` is a rendered string, so it can't be
656+
# searched structurally — the persisted request is the source of truth.
657+
persisted_request = (job.job_metadata or {}).get("request") or {}
658+
effective_session_id = persisted_request.get("session_id") or flow_id_str
659+
660+
# Default GET-status path: rebuild from the protocol-neutral terminal
661+
# captures the runner stored in ``Job.result``. This needs no
662+
# ``vertex_build`` rows, so it works with vertex-build storage off
663+
# (headless) and skips graph reconstruction.
664+
result = job.result if isinstance(job.result, dict) else {}
665+
output_events = result.get("outputs") or []
666+
partial_stored_response: WorkflowExecutionResponse | None = None
667+
if isinstance(output_events, list) and output_events:
668+
try:
669+
return workflow_response_from_output_events(
670+
output_events,
671+
flow_id=flow_id_str,
672+
job_id=job_id_str,
673+
session_id=effective_session_id,
674+
fail_on_rejected=True,
675+
)
676+
except ValueError:
677+
# Preserve every decodable capture in case the legacy
678+
# vertex-build fallback is also unavailable.
679+
partial_stored_response = workflow_response_from_output_events(
680+
output_events,
681+
flow_id=flow_id_str,
682+
job_id=job_id_str,
683+
session_id=effective_session_id,
684+
)
685+
686+
# Fallback: ``Job.result`` carried no outputs, has an invalid shape,
687+
# contains rejected/version-skewed entries, or predates capture.
688+
# Reconstruct from ``vertex_build`` rows keyed by job_id when present.
652689
try:
653-
return await reconstruct_workflow_response_from_job_id(
690+
reconstructed = await reconstruct_workflow_response_from_job_id(
654691
session=session,
655692
flow=flow,
656693
job_id=job_id_str,
657694
user_id=str(current_user.id),
658695
)
659696
except ValueError:
660-
# Rebuild the result from the ``output`` events the runner
661-
# captured into ``Job.result`` (langflow-protocol runs). Falls
662-
# back to a bare COMPLETED when none were captured (e.g. an
663-
# agui-protocol run, where the result lives only on /events).
664-
result = job.result if isinstance(job.result, dict) else {}
697+
if partial_stored_response is not None:
698+
return partial_stored_response
665699
return workflow_response_from_output_events(
666-
result.get("outputs") or [],
700+
[],
667701
flow_id=flow_id_str,
668702
job_id=job_id_str,
703+
session_id=effective_session_id,
669704
)
705+
else:
706+
if reconstructed.session_id is None:
707+
reconstructed = reconstructed.model_copy(update={"session_id": effective_session_id})
708+
return reconstructed
670709

671710
if job.status == JobStatus.FAILED:
672711
# Surface the durable error JSON the runner persisted, additively.

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

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from lfx.schema.schema import InputValueRequest
3838
from lfx.schema.workflow import JobStatus, WorkflowExecutionResponse
3939
from lfx.workflow.adapters import StreamAdapter, StreamEvent
40+
from lfx.workflow.adapters.langflow import WORKFLOW_OUTPUT_CAPTURE_EVENT, build_terminal_output_event
4041
from lfx.workflow.converters import ParsedWorkflowRun, create_error_response, run_response_to_workflow_response
4142

4243
from langflow.api.utils import extract_global_variables_from_headers
@@ -185,6 +186,7 @@ async def _stream_event_frames(
185186
job_id: UUID | None = None,
186187
resume: dict | None = None,
187188
track_job_status: bool = True,
189+
emit_output_capture: bool = False,
188190
) -> AsyncIterator[tuple[bytes, str]]:
189191
"""Run a flow via the v1 build-vertex loop, dispatch its events through ``adapter``.
190192
@@ -321,6 +323,26 @@ def _frame(stream_event: StreamEvent, seq: int) -> tuple[bytes, str]:
321323
seq,
322324
)
323325
seq += 1
326+
# Off-wire terminal-output capture for ``Job.result`` (background only).
327+
# Synthesized from the RAW ``end_vertex`` here — before ``adapter.translate``
328+
# — so it is protocol-neutral: the ``agui`` adapter emits no wire ``output``
329+
# event, so without this its background GET-status would carry no outputs.
330+
# The runner captures this frame in-memory only (never persisted to
331+
# ``job_events``, never published), so the wire is unchanged for every
332+
# protocol and the capture is independent of durable-event storage.
333+
if emit_output_capture and event_type == "end_vertex":
334+
output = build_terminal_output_event(event_data)
335+
if output is not None:
336+
capture_payload = {"event": "output", "data": output.model_dump(mode="json")}
337+
yield _frame(
338+
StreamEvent(
339+
type=WORKFLOW_OUTPUT_CAPTURE_EVENT,
340+
data_json=json.dumps(capture_payload, default=str),
341+
),
342+
seq,
343+
)
344+
seq += 1
345+
324346
for event in adapter.translate(event_type, event_data):
325347
if terminal_error_type is not None and event.type == terminal_error_type:
326348
terminal_error_seen = True
@@ -430,6 +452,33 @@ async def execute_sync_workflow_with_timeout(
430452
raise WorkflowTimeoutError from e
431453

432454

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+
The run has already executed and succeeded upstream; this helper only caches
465+
the finished result, so EVERY exception it can raise (serialization or the
466+
``set_result`` DB write — e.g. a SQLite ``OperationalError`` under lock
467+
contention) is a persistence failure, never a workflow failure. Catch broadly
468+
so such an error cannot escape to the caller's terminal ``except Exception`` and
469+
be misreported as a failed run. ``asyncio.CancelledError`` is a ``BaseException``
470+
and still propagates, so timeouts/disconnects are unaffected.
471+
"""
472+
try:
473+
output_events = [
474+
{"component_id": component_id, **output.model_dump(mode="json")}
475+
for component_id, output in (workflow_response.outputs or {}).items()
476+
]
477+
await job_service.set_result(job_id, {"status": "completed", "outputs": output_events})
478+
except Exception: # noqa: BLE001 — best-effort cache; the response is already built inline
479+
await logger.awarning("Sync result persistence failed for flow %s", flow_id, exc_info=True)
480+
481+
433482
async def execute_sync_workflow(
434483
parsed: ParsedWorkflowRun,
435484
flow: FlowRead,
@@ -554,7 +603,7 @@ async def execute_sync_workflow(
554603
# Build RunResponse
555604
run_response = RunResponse(outputs=task_result, session_id=execution_session_id)
556605
# Convert to WorkflowExecutionResponse
557-
return run_response_to_workflow_response(
606+
workflow_response = run_response_to_workflow_response(
558607
run_response=run_response,
559608
flow_id=parsed.flow_id,
560609
job_id=str(job_id),
@@ -563,6 +612,11 @@ async def execute_sync_workflow(
563612
effective_globals=request_variables,
564613
selected_ids=parsed.output_ids,
565614
)
615+
# Persist the terminal outputs to Job.result so a later GET status on this
616+
# sync job_id returns the same outputs the background path stores (same
617+
# list-of-OutputEvent shape, so the status read is protocol-uniform).
618+
await _persist_sync_result(job_service, job_id, workflow_response, flow.id)
619+
return workflow_response # noqa: TRY300 — keep response-building under the broad except below
566620

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

src/backend/base/langflow/services/background_execution/runner.py

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from typing import TYPE_CHECKING, Any
2424

2525
from lfx.log.logger import logger
26+
from lfx.workflow.adapters.langflow import WORKFLOW_OUTPUT_CAPTURE_EVENT
2627

2728
from langflow.services.background_execution.live_bus import LiveFrame
2829
from langflow.services.database.models.jobs.model import JobStatus, SignalType
@@ -146,15 +147,21 @@ async def _suspend(self, job_id: UUID, exc: PauseRequested) -> None:
146147
finalizer, or close the live bus — the run is resumable.
147148
"""
148149
await self._jobs.append_event(job_id, HUMAN_INPUT_REQUIRED_EVENT, exc.payload)
149-
await self._jobs.update_job_status(job_id, JobStatus.SUSPENDED)
150150
metadata: dict[str, Any] = {}
151151
if exc.request_id is not None:
152152
metadata["pending_request_id"] = exc.request_id
153153
if self._input_deadline_s is not None:
154154
deadline = datetime.now(timezone.utc) + timedelta(seconds=self._input_deadline_s)
155155
metadata["input_deadline_at"] = deadline.isoformat()
156-
if metadata:
157-
await self._jobs.update_job_metadata(job_id, metadata)
156+
if exc.output_events:
157+
# Durable stash of the pre-pause terminal outputs so the resumed pass can
158+
# pre-seed its capture list. Deduped so a resume-pause-resume cycle does
159+
# not accumulate duplicates (the pre-seed already folds the prior stash in).
160+
metadata["pre_pause_outputs"] = self._dedup_outputs(exc.output_events)
161+
# Publish SUSPENDED only when the resume guard and pre-pause captures are
162+
# visible in the same committed row. Otherwise a racing resume can claim
163+
# the job between separate status and metadata transactions.
164+
await self._jobs.suspend_job(job_id, metadata)
158165

159166
async def _finalize_terminal_event(self, job_id: UUID) -> None:
160167
"""Backfill the error blob + terminal event for TIMED_OUT / CANCELLED.
@@ -232,20 +239,42 @@ async def _drive(self, *, job_id: UUID, source_kwargs: dict[str, Any]) -> None:
232239
last_durable_seq = 0
233240
errored_payload: dict[str, Any] | None = None
234241
# Terminal outputs the run produced, captured so GET status can return
235-
# the result without forcing a /events re-attach. The langflow adapter
236-
# normalizes each terminal output into a durable ``output`` event whose
237-
# ``data`` is the same ``OutputEvent`` (a ``ComponentOutput`` plus its
238-
# component id) that sync returns in ``outputs[id]``. The agui adapter
239-
# does not emit these, so agui-protocol runs leave this empty and their
240-
# status stays result-less (the result is still on the /events log).
241-
output_events: list[dict[str, Any]] = []
242+
# the result without forcing a /events re-attach. The frame source emits
243+
# a protocol-neutral off-wire capture for both langflow and AG-UI. The
244+
# public langflow ``output`` event is also accepted as a fallback for
245+
# injected/exported FrameSource implementations that do not emit the
246+
# private capture; final deduplication keeps the two paths from doubling.
247+
#
248+
# A resumed run starts a FRESH frame stream and the output loop skips
249+
# re-emitting checkpoint-restored vertices, so any terminal output the
250+
# pre-pause pass produced would be lost. Pre-seed from the stash _suspend
251+
# wrote to job_metadata; _dedup_outputs lets a re-emitted vertex overwrite
252+
# its stale entry at finalize (resumed pass wins).
253+
output_events: list[dict[str, Any]] = await self._load_pre_pause_outputs(job_id) if resume is not None else []
242254
async for frame_bytes, event_type in self._frame_source(**source_kwargs):
255+
if event_type == WORKFLOW_OUTPUT_CAPTURE_EVENT:
256+
# Off-wire terminal-output capture (both protocols): record it into
257+
# the in-memory result ONLY. Deliberately no ``append_event`` (never
258+
# in ``job_events``) and no ``publish`` (never on the live bus), so the
259+
# wire is unchanged and both protocols populate ``Job.result``.
260+
payload = self._decode_payload(frame_bytes)
261+
output_data = payload.get("data")
262+
if isinstance(output_data, dict):
263+
output_events.append(output_data)
264+
continue
243265
if event_type == HUMAN_INPUT_REQUIRED_EVENT:
244266
from langflow.services.jobs.service import _unwrap_pause_payload
245267

246268
payload = self._decode_payload(frame_bytes)
247269
request = _unwrap_pause_payload(payload) or {}
248-
raise PauseRequested(payload=payload, request_id=request.get("request_id"))
270+
# Carry the pre-pause captures out with the exception so _suspend can
271+
# stash them; without this the resumed pass starts empty and the final
272+
# result drops every terminal output produced before the pause.
273+
raise PauseRequested(
274+
payload=payload,
275+
request_id=request.get("request_id"),
276+
output_events=output_events,
277+
)
249278
if self._adapter.is_durable(event_type):
250279
# Vertex/milestone-boundary cooperative cancel: a STOP written to
251280
# the durable signal table flips the job at the next durable
@@ -258,12 +287,14 @@ async def _drive(self, *, job_id: UUID, source_kwargs: dict[str, Any]) -> None:
258287
seq = await self._jobs.append_event(job_id, event_type, payload)
259288
last_durable_seq = seq
260289
await self._bus.publish(str(job_id), LiveFrame(seq=seq, data=self._restamp_id(frame_bytes, seq)))
261-
if event_type == self._adapter.terminal_error_type:
262-
errored_payload = payload
263-
elif event_type == "output":
290+
if event_type == "output":
291+
# Fallback for custom frame sources that emit the standard
292+
# adapter event but not WORKFLOW_OUTPUT_CAPTURE_EVENT.
264293
output_data = payload.get("data")
265294
if isinstance(output_data, dict):
266295
output_events.append(output_data)
296+
if event_type == self._adapter.terminal_error_type:
297+
errored_payload = payload
267298
else:
268299
await self._bus.publish(
269300
str(job_id),
@@ -287,7 +318,42 @@ async def _drive(self, *, job_id: UUID, source_kwargs: dict[str, Any]) -> None:
287318
# Surface as a failure so execute_with_status writes FAILED.
288319
msg = "Background job emitted a terminal error event"
289320
raise RuntimeError(msg)
290-
await self._jobs.set_result(job_id, {"status": "completed", "outputs": output_events})
321+
await self._jobs.set_result(job_id, {"status": "completed", "outputs": self._dedup_outputs(output_events)})
322+
323+
async def _load_pre_pause_outputs(self, job_id: UUID) -> list[dict[str, Any]]:
324+
"""Return terminal outputs a prior suspend stashed in job_metadata, or [].
325+
326+
Read once at the top of a resumed ``_drive`` to seed the capture list so
327+
outputs produced before the pause survive into the completed-run result.
328+
"""
329+
job = await self._jobs.get_job_by_job_id(job_id)
330+
prior = (job.job_metadata or {}).get("pre_pause_outputs") if job else None
331+
if isinstance(prior, list):
332+
return [event for event in prior if isinstance(event, dict)]
333+
return []
334+
335+
@staticmethod
336+
def _dedup_outputs(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
337+
"""Merge pre-pause and resumed-pass captures keyed by ``component_id``.
338+
339+
A resumed run re-emits the branch vertices it had to rebuild, so one
340+
``component_id`` can appear both in the pre-seeded pre-pause outputs and in
341+
the fresh captures. Keep each component's FIRST position (pre-pause outputs
342+
stay ahead of ones produced after resume) but its LATEST value (the resumed
343+
pass wins for anything it re-emitted). Events without a ``component_id`` are
344+
passed through untouched, in order.
345+
"""
346+
index: dict[str, int] = {}
347+
merged: list[dict[str, Any]] = []
348+
for event in events:
349+
cid = event.get("component_id")
350+
if cid is not None and cid in index:
351+
merged[index[cid]] = event
352+
continue
353+
if cid is not None:
354+
index[cid] = len(merged)
355+
merged.append(event)
356+
return merged
291357

292358
async def _maybe_resume(self, job_id: UUID) -> dict[str, Any] | None:
293359
data = await self._pending_resume(job_id)

src/backend/base/langflow/services/jobs/exceptions.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,17 @@ class PauseRequested(JobError): # noqa: N818
2828
re-raises it without writing a terminal status.
2929
"""
3030

31-
def __init__(self, payload: dict | None = None, request_id: str | None = None) -> None:
31+
def __init__(
32+
self,
33+
payload: dict | None = None,
34+
request_id: str | None = None,
35+
output_events: list[dict] | None = None,
36+
) -> None:
3237
super().__init__("Run paused for human input")
3338
self.payload = payload or {}
3439
self.request_id = request_id
40+
# Terminal outputs the run captured BEFORE the pause (e.g. a fully-run
41+
# sibling branch ending in an output component). The runner stashes these
42+
# on suspend so the resumed pass — which starts a fresh capture list and
43+
# skips re-emitting already-built vertices — does not drop them.
44+
self.output_events = output_events or []

0 commit comments

Comments
 (0)