@@ -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+
135295async 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
0 commit comments