Skip to content

Commit 2a07971

Browse files
Janardan S Kaviaclaude
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>
1 parent 64cfb92 commit 2a07971

8 files changed

Lines changed: 315 additions & 45 deletions

File tree

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

Lines changed: 35 additions & 11 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,11 +648,35 @@ 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 durable ``output`` events
661+
# the runner captured into ``Job.result`` (langflow-protocol). This
662+
# needs no ``vertex_build`` rows, so it works with vertex-build storage
663+
# off (headless), and skips the graph reconstruction the vertex-build
664+
# path does.
665+
result = job.result if isinstance(job.result, dict) else {}
666+
output_events = result.get("outputs") or []
667+
if output_events:
668+
return workflow_response_from_output_events(
669+
output_events,
670+
flow_id=flow_id_str,
671+
job_id=job_id_str,
672+
session_id=effective_session_id,
673+
)
674+
675+
# Fallback: ``Job.result`` carried no outputs (an agui-protocol run
676+
# leaves it empty; the terminal output lives only on /events) or a
677+
# legacy job predates output capture. Reconstruct from the
678+
# ``vertex_build`` rows keyed by job_id when they exist; if none do,
679+
# ValueError degrades to a bare COMPLETED with an empty outputs map.
652680
try:
653681
return await reconstruct_workflow_response_from_job_id(
654682
session=session,
@@ -657,15 +685,11 @@ async def get_workflow_status(
657685
user_id=str(current_user.id),
658686
)
659687
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 {}
665688
return workflow_response_from_output_events(
666-
result.get("outputs") or [],
689+
[],
667690
flow_id=flow_id_str,
668691
job_id=job_id_str,
692+
session_id=effective_session_id,
669693
)
670694

671695
if job.status == JobStatus.FAILED:

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

Lines changed: 22 additions & 0 deletions
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

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

Lines changed: 31 additions & 5 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
@@ -239,7 +240,25 @@ async def _drive(self, *, job_id: UUID, source_kwargs: dict[str, Any]) -> None:
239240
# does not emit these, so agui-protocol runs leave this empty and their
240241
# status stays result-less (the result is still on the /events log).
241242
output_events: list[dict[str, Any]] = []
243+
# Durable event-log storage toggle. When off, the per-milestone job_events
244+
# writes are skipped (reattach/replay via GET /events is unavailable); live
245+
# streaming and the completed-run Job.result are unaffected. Read once so a
246+
# mid-run settings flip cannot desync the seq monotonicity within this run.
247+
from lfx.services.deps import get_settings_service
248+
249+
persist_events = get_settings_service().settings.job_events_storage_enabled
242250
async for frame_bytes, event_type in self._frame_source(**source_kwargs):
251+
if event_type == WORKFLOW_OUTPUT_CAPTURE_EVENT:
252+
# Off-wire terminal-output capture (both protocols): record it into
253+
# the in-memory result ONLY. Deliberately no ``append_event`` (never
254+
# in ``job_events``) and no ``publish`` (never on the live bus), so
255+
# the wire is unchanged and ``Job.result`` fills independently of
256+
# durable-event storage — even if job-event writing were disabled.
257+
payload = self._decode_payload(frame_bytes)
258+
output_data = payload.get("data")
259+
if isinstance(output_data, dict):
260+
output_events.append(output_data)
261+
continue
243262
if event_type == HUMAN_INPUT_REQUIRED_EVENT:
244263
from langflow.services.jobs.service import _unwrap_pause_payload
245264

@@ -255,15 +274,22 @@ async def _drive(self, *, job_id: UUID, source_kwargs: dict[str, Any]) -> None:
255274
if await self._stop_requested(job_id):
256275
raise self._user_cancelled()
257276
payload = self._decode_payload(frame_bytes)
258-
seq = await self._jobs.append_event(job_id, event_type, payload)
277+
if persist_events:
278+
seq = await self._jobs.append_event(job_id, event_type, payload)
279+
else:
280+
# Event-log storage off: keep a local monotonic seq so live
281+
# subscribers still get ordered frames; there is no durable row
282+
# to read, so reattach/replay is intentionally unavailable.
283+
seq = last_durable_seq + 1
259284
last_durable_seq = seq
260285
await self._bus.publish(str(job_id), LiveFrame(seq=seq, data=self._restamp_id(frame_bytes, seq)))
261286
if event_type == self._adapter.terminal_error_type:
262287
errored_payload = payload
263-
elif event_type == "output":
264-
output_data = payload.get("data")
265-
if isinstance(output_data, dict):
266-
output_events.append(output_data)
288+
# NOTE: terminal outputs are captured off-wire via the
289+
# WORKFLOW_OUTPUT_CAPTURE_EVENT frame above (protocol-neutral), not
290+
# from the langflow adapter's durable wire ``output`` event — that
291+
# left agui-protocol ``Job.result`` empty. The wire ``output`` still
292+
# flows to streaming clients via ``append_event``/``publish`` here.
267293
else:
268294
await self._bus.publish(
269295
str(job_id),

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

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,166 @@ async def test_background_status_returns_output(client, created_api_key, bg_flow
132132
assert body["outputs"], f"completed background status carried no outputs: {body}"
133133

134134

135+
async def test_background_status_from_job_table_with_vertex_builds_off(client, created_api_key, bg_flow):
136+
"""With vertex_build storage OFF, GET status must still carry the full output.
137+
138+
Proves the headless-executor path: disable ``vertex_builds_storage_enabled`` so
139+
NO vertex_build rows are written, run a background job, then assert the GET
140+
status output is sourced from the durable ``Job.result`` blob (the fallback the
141+
COMPLETED branch takes when vertex-build reconstruction finds nothing).
142+
143+
Three proofs: (1) zero vertex_build rows keyed by job_id, (2) ``Job.result``
144+
holds the captured outputs, (3) GET status returns a non-empty ``outputs`` map.
145+
"""
146+
from uuid import UUID
147+
148+
from langflow.services.database.models.jobs.model import Job, JobStatus
149+
from langflow.services.database.models.vertex_builds.crud import get_vertex_builds_by_job_id
150+
from lfx.services.deps import get_settings_service
151+
152+
settings = get_settings_service().settings
153+
original = settings.vertex_builds_storage_enabled
154+
settings.vertex_builds_storage_enabled = False
155+
try:
156+
submit = await client.post("api/v2/workflows", json=_body(bg_flow), headers=_headers(created_api_key))
157+
assert submit.status_code == 200, submit.text
158+
job_id = submit.json()["job_id"]
159+
160+
row = None
161+
for _ in range(200):
162+
async with session_scope() as session:
163+
row = await session.get(Job, UUID(job_id))
164+
if row is not None and row.status in (
165+
JobStatus.COMPLETED,
166+
JobStatus.FAILED,
167+
JobStatus.TIMED_OUT,
168+
):
169+
break
170+
await asyncio.sleep(0.1)
171+
assert row is not None, "job row was never created"
172+
assert row.status == JobStatus.COMPLETED, f"job did not complete: {row.status}"
173+
174+
# Proof 1: storage OFF => no vertex_build rows persisted for this job_id.
175+
async with session_scope() as session:
176+
vbs = await get_vertex_builds_by_job_id(session, job_id)
177+
assert not vbs, f"vertex_builds were written despite storage OFF: {len(vbs)} rows"
178+
179+
# Proof 2: the durable Job.result blob carries the captured terminal outputs.
180+
assert isinstance(row.result, dict), f"Job.result is not a dict: {row.result!r}"
181+
assert row.result.get("outputs"), f"Job.result carried no outputs: {row.result}"
182+
183+
# Proof 3: GET status returns the full output, sourced from Job.result (reconstruct
184+
# finds nothing with storage off, so the COMPLETED branch falls back to Job.result).
185+
status = await client.get("api/v2/workflows", params={"job_id": job_id}, headers=_headers(created_api_key))
186+
assert status.status_code == 200, status.text
187+
body = status.json()
188+
assert body["status"] == "completed"
189+
assert body["outputs"], f"GET status carried no outputs with vertex_builds OFF: {body}"
190+
# Proof 4: the Job.result path recovers the session_id from the terminal
191+
# events (parity with the vertex-build path), so a background GET can
192+
# continue the same chat thread even with vertex-build storage off.
193+
assert body.get("session_id"), f"GET status lost session_id with vertex_builds OFF: {body}"
194+
finally:
195+
settings.vertex_builds_storage_enabled = original
196+
197+
198+
async def test_background_agui_populates_job_result_outputs(client, created_api_key, bg_flow):
199+
"""An agui-protocol background run now fills ``Job.result.outputs`` too.
200+
201+
Regression guard for the off-wire capture (WORKFLOW_OUTPUT_CAPTURE_EVENT): the
202+
agui adapter emits no wire ``output`` event, so the runner used to leave
203+
``Job.result`` result-less and a GET status carried an empty ``outputs``. The
204+
frame source now synthesizes a protocol-neutral capture frame from the raw
205+
``end_vertex``, which the runner records into ``Job.result`` without touching
206+
``job_events`` or the live bus. Proves: (1) ``Job.result`` carries outputs for
207+
an agui run, (2) GET status returns a non-empty ``outputs`` map.
208+
"""
209+
from uuid import UUID
210+
211+
from langflow.services.database.models.jobs.model import Job, JobStatus
212+
213+
body = {**_body(bg_flow), "stream_protocol": "agui"}
214+
submit = await client.post("api/v2/workflows", json=body, headers=_headers(created_api_key))
215+
assert submit.status_code == 200, submit.text
216+
job_id = submit.json()["job_id"]
217+
218+
row = None
219+
for _ in range(200):
220+
async with session_scope() as session:
221+
row = await session.get(Job, UUID(job_id))
222+
if row is not None and row.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.TIMED_OUT):
223+
break
224+
await asyncio.sleep(0.1)
225+
assert row is not None, "agui job row was never created"
226+
assert row.status == JobStatus.COMPLETED, f"agui job did not complete: {row.status}"
227+
228+
# Proof 1: the durable Job.result blob carries the captured terminal outputs
229+
# even though agui emitted no wire ``output`` event.
230+
assert isinstance(row.result, dict), f"Job.result is not a dict: {row.result!r}"
231+
assert row.result.get("outputs"), f"agui Job.result carried no outputs: {row.result}"
232+
233+
# Proof 2: GET status returns the full output, sourced from Job.result.
234+
status = await client.get("api/v2/workflows", params={"job_id": job_id}, headers=_headers(created_api_key))
235+
assert status.status_code == 200, status.text
236+
status_body = status.json()
237+
assert status_body["status"] == "completed"
238+
assert status_body["outputs"], f"agui GET status carried no outputs: {status_body}"
239+
240+
241+
async def test_background_with_job_events_storage_off(client, created_api_key, bg_flow):
242+
"""With job-event storage OFF, the run still completes and GET carries full output.
243+
244+
Proves the headless toggle: disable ``job_events_storage_enabled`` so the durable
245+
milestone log is NOT written, run a background job, then assert (1) the run still
246+
reaches COMPLETED, (2) no streaming ``job_events`` rows were persisted, (3) the
247+
completed-run ``Job.result`` still carries outputs and GET status returns them —
248+
because Job.result is a separate job-table write, independent of job_events.
249+
"""
250+
from uuid import UUID
251+
252+
from langflow.services.database.models.jobs.model import Job, JobEvent, JobStatus
253+
from lfx.services.deps import get_settings_service
254+
from sqlalchemy import func, select
255+
256+
settings = get_settings_service().settings
257+
original = settings.job_events_storage_enabled
258+
settings.job_events_storage_enabled = False
259+
try:
260+
submit = await client.post("api/v2/workflows", json=_body(bg_flow), headers=_headers(created_api_key))
261+
assert submit.status_code == 200, submit.text
262+
job_id = submit.json()["job_id"]
263+
264+
row = None
265+
for _ in range(200):
266+
async with session_scope() as session:
267+
row = await session.get(Job, UUID(job_id))
268+
if row is not None and row.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.TIMED_OUT):
269+
break
270+
await asyncio.sleep(0.1)
271+
assert row is not None, "job row was never created"
272+
assert row.status == JobStatus.COMPLETED, f"job did not complete: {row.status}"
273+
274+
# Proof 1: no streaming milestone rows were written for this job.
275+
async with session_scope() as session:
276+
count = await session.scalar(
277+
select(func.count()).select_from(JobEvent).where(JobEvent.job_id == UUID(job_id))
278+
)
279+
assert count == 0, f"job_events rows were written despite storage OFF: {count}"
280+
281+
# Proof 2: Job.result (separate job-table write) still carries outputs.
282+
assert isinstance(row.result, dict), f"Job.result is not a dict: {row.result!r}"
283+
assert row.result.get("outputs"), f"Job.result empty: {row.result}"
284+
285+
# Proof 3: GET status returns the full output, sourced from Job.result.
286+
status = await client.get("api/v2/workflows", params={"job_id": job_id}, headers=_headers(created_api_key))
287+
assert status.status_code == 200, status.text
288+
body = status.json()
289+
assert body["status"] == "completed"
290+
assert body["outputs"], f"GET status carried no outputs with job_events OFF: {body}"
291+
finally:
292+
settings.job_events_storage_enabled = original
293+
294+
135295
async def test_stop_does_not_overwrite_completed_job(client, created_api_key, bg_flow):
136296
"""A late ``/stop`` on an already-COMPLETED job must NOT flip it to CANCELLED.
137297

src/lfx/src/lfx/services/settings/groups/telemetry.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ class TelemetrySettings(BaseModel):
2020
"""If set to True, Langflow will track transactions between flows."""
2121
vertex_builds_storage_enabled: bool = True
2222
"""If set to True, Langflow will keep track of each vertex builds (outputs) in the UI for any flow."""
23+
job_events_storage_enabled: bool = True
24+
"""If set to True, background runs persist their durable event log to the ``job_events`` table.
25+
26+
This log powers the ``GET /api/v2/workflows/{job_id}/events`` reattach/replay (resume from
27+
``Last-Event-ID``). Set to False for a headless executor that never re-attaches to a run's stream:
28+
the per-milestone DB writes are skipped and reattach/replay returns nothing. Live streaming while
29+
connected and the completed-run ``Job.result`` (GET-status full output) are UNAFFECTED — those do
30+
not depend on this table."""
2331

2432
telemetry_writer_enabled: bool = True
2533
"""Route transaction and vertex_build writes through an async batched writer backed by a

0 commit comments

Comments
 (0)