Skip to content

Commit 7025c44

Browse files
ogabrielluizautofix-ci[bot]
authored andcommitted
feat(api): durable background execution service (store + default backend) (#13507)
* feat: native v2 workflows endpoint with pluggable stream protocols Rebased onto release-1.10.0. The base independently rebuilt the v2 workflows backend (RBAC, body globals, share-aware fetch); keep our forward design and conform its auth to that work: 1. Auth: keep get_current_user_for_workflow (session-or-API-key authN that does not hold a DB connection during the inline run, avoiding the SQLite lock contention api_key_security would cause) and enforce the base's RBAC on top: ensure_flow_permission(EXECUTE) before run, (READ) before status reconstruct, with widen_for_shares fetch. 2. Port the base's request-body globals onto the v2 WorkflowRunRequest. The X-LANGFLOW-GLOBAL-VAR-* headers stay supported (the Responses API passes globals that way); body globals win on conflict. Converters echo the effective globals via effective_globals. 3. Public endpoint keeps the v1 build_public_tmp posture (access_type==PUBLIC, run-as-owner); RBAC applies to the authenticated endpoint only. 4. Preserve the base's post-build KB-cache invalidation in the AG-UI build path. The endpoint, AG-UI bridge, pluggable stream adapters, public endpoint, and re-attach are unchanged. * feat(api): add output_text and session_id to v2 workflow response The synchronous /api/v2/workflows response keyed every result under its component id, so reading the answer meant knowing an id you can't predict. Surface two additive fields: - output_text: the flow's single text answer (ChatOutput/TextOutput). None when the flow has zero or multiple text outputs, so callers read outputs rather than the shortcut guessing which channel is the answer. - session_id: echoes the resolved session so chat/memory callers can continue the same thread (v1 /run returned this; v2 had dropped it). outputs is unchanged, so this is non-breaking. * test(api/v2): cover output_text and session_id on the v2 workflow response Pin the sync-response shortcuts on the v2 workflows endpoint: - output_text surfaces the lone ChatOutput/TextOutput text and stays None for non-output message nodes, data-only flows, and multi-text flows - session_id echoes the resolved session; the error response exposes neither - each outputs entry exposes only {type, status, content, metadata}, with the component id carried by the dict key Also drop the component_id kwarg the converter passed to ComponentOutput, which has no such field and silently dropped it. * feat(api/v2): structured output with resolution reason on v2 response Replace the flat output_text shortcut with an `output` object carrying the resolved text answer plus a `reason` that explains why it resolved that way (single/multiple/none/non_string/failed), so a null answer is always diagnosable instead of silently None. `reason` follows the LLM-domain finish_reason/stop_reason convention, distinct from the lifecycle status. Also add `display_name` to each ComponentOutput (the stable component id stays the dict key) and a computed `has_errors` flag derived from errors. * feat(api/v2): add request-side output selection (output_ids) Let a sync caller name the output(s) they want via output_ids so output.text resolves deterministically (reason=single) on multi-output flows instead of going null. Selection is steer-only: it picks the answer among the named outputs without filtering the outputs map. Invalid ids are rejected with 422 before the flow runs (and before any job row is created), so a typo costs no compute. Resolution considers selected outputs that actually fired, so branching flows resolve to whichever candidate ran. * feat(api/v2): emit per-output events on the langflow stream Give v2-workflows sync and the langflow stream protocol one parser. The stream now emits a normalized "output" event per terminal output carrying an OutputEvent (the ComponentOutput shape sync returns in outputs[id], plus component_id). A shared build_component_output() backs both the sync converter and the adapter, and the build loop ships authoritative vertex metadata as an additive output_meta key on end_vertex (existing consumers read build_data and ignore it). This is access-pattern parity (one parser, same fields, same terminal set), not byte-identical content: the stream reuses the v1 build path whose display serialization differs from sync's run_graph output. * feat(api/v2): durable background execution service (store + default backend) Turns v2 mode:background into a durable, in-API background execution service behind a BackgroundExecutionService facade. Adds the store layer (result/error columns, job_events durable milestone log, execution_signals control, heartbeat/lease, 3 migrations), the default backend (bounded executor, runner, in-memory live bus, liveness-aware single-flight orphan sweep), the v2 endpoint rewiring, and the real-instance test harness. Needs no new infra; works on the SQLite single-process install. The redis-scaled worker backend is stacked on top in a follow-up PR. * test(background-execution): rename hard_proof marker to real_services The hard_proof marker name was a vibe word that said nothing about what the tests need. Rename it to real_services everywhere: the pytest marker registration, the *_hard_proof.py test files, the Makefile target (real_services_tests), the -m selector in migration-validation.yml, and the CI job. real_services says what these tests require: real Postgres + Redis + worker subprocesses. (integration was already taken for the external-API suite under tests/integration.) * fix(api/v2): enforce no-code-execution gate on public workflow endpoint The v2 public endpoint only ran validate_flow_for_current_settings and skipped validate_public_flow_no_code_execution, which the v1 build_public_tmp path applies. A public flow containing a Python interpreter/REPL (or the legacy Python Code Structured tool, Smart Transform lambda) was therefore an unauthenticated server-side code-execution primitive (report H1-3754930). Mirror v1: import the validator and call it right after the public-access gate. PublicFlowValidationError subclasses CustomComponentValidationError, so the existing handler already sanitizes it to a 400 'This flow cannot be executed.' without leaking the blocked component class names. Add a non-mocking test that builds a public flow with a real PythonREPLComponent and asserts the sanitized 400 (verified RED: returns 200 without the gate). LE-1389 * fix(api/v2): reconstruct background workflow status from job-keyed vertex builds A completed background job's GET status 500'd with 'No vertex builds found for job_id'. The background build path differed from the sync path twice: 1. generate_flow_events minted a fresh run_id instead of using job_id, so vertex builds were keyed by an id the status query never uses. Thread run_id through _stream_event_frames -> generate_flow_events and pass job_id from the background buffer so graph.run_id == job_id (the sync path already does graph.set_run_id(job_id)). 2. The SSE build loop (build_vertices) only persisted builds when log_builds was set and never passed job_id. Tie log_builds to job-tracked runs (run_id present) and pass job_id=graph.run_id on the persist call. Job-tracked runs also persist streaming terminal vertices so reconstruction is complete; the live build path (run_id is None) keeps its original behavior, so the v1 build path is unchanged. Test: a real background run polled to completion, then GET status asserts a reconstructed 200 (verified RED: 500 'No vertex builds found' before the fix). Covers the non-streaming flow. v1 build path unchanged (35 build tests pass); AG-UI suite 46 pass. LE-1389 * fix(api/v2): merge workflow AG-UI cancellation hardening LE-1389 * fix(api/v2): signal cross-worker workflow stops LE-1389 * fix(api/v2): report unconfirmed workflow stops LE-1389 * fix(api/v2): keep background workflows out of polling watchdog LE-1389 * fix(api/v2): buffer parallel messages in the AG-UI translator instead of dropping them Parallel components stream tokens for different message ids interleaved. The translator tracked a single open message: the first foreign token closed the open message and tombstoned its id, so every later event for it was dropped and its remaining text never reached the client. Tokens for a message that cannot take the wire now buffer until the open message genuinely ends (its add_message finalizer), then flush in arrival order; complete messages landing mid-stream buffer the same way instead of interleaving a second START. end/error drain all buffers before the terminal event. The wire still carries at most one open text message, so the stream stays AG-UI-conformant. * fix(api/v2): gate AG-UI message finalization on non-partial state and purge removed buffers A partial add_message re-fire (the agent path emits these at tool start/end for a message it is still streaming) was treated as the finalizer: it closed and tombstoned the id, so the post-tool answer was dropped. Only a non-partial add_message finalizes now; state defaults to complete, so payloads without properties are unchanged. remove_message now purges a buffered message and tombstones its id, so text the backend retracted is not flushed to the client later. * Fix AG-UI workflow lifecycle edges * [autofix.ci] apply automated fixes * fix(frontend): enable downlevelIteration for jest Set/Map iteration ts-jest compiles with target es5; without downlevelIteration, [...set] and for...of over a Set/Map emit ES5 that yields nothing. That silently broke the AG-UI bridge tests: runningNodeIds spread, markRunningNodesFailed, and restoreOriginalBuildStatuses all iterated empty. Production (Vite/SWC, modern target) was never affected; only the ts-jest harness was. Fixes the 3 failing jest tests on this branch with no other suite changes (4994/4994 pass). * fix(api/v2): surface inactivated branch vertices over AG-UI A branch component (If-Else, Conditional Router) reports its not-taken vertices in build_data.inactivated_vertices, but the AG-UI translator only emitted the branch node's own success/error status and dropped that list. The canvas seeds every planned node as pending from vertices_sorted; skipped vertices then get no build_start/end_vertex, so they stayed stuck on pending instead of rendering as inactive (the v1 build path marked them INACTIVE). The translator now appends an inactive STATE_DELTA op per inactivated vertex, and the frontend bridge maps the new inactive status to BuildStatus.INACTIVE and tears its edges down like a completed node. Fixes the If-Else regression in general-bugs-reset-flow-run.spec.ts. * fix(api/v2): dedupe repeated inactive node deltas in AG-UI stream build.py keeps reporting a conditionally-excluded vertex in inactivated_vertices on every subsequent end_vertex (the excluded set persists until the ConditionalRouter clears it), so the translator was putting the same inactive STATE_DELTA on the wire once per remaining vertex. Track emitted inactive nodes and skip re-emitting; drop a node from the set when it actually runs again (build_start/end_vertex) so a loop re-activation can still re-emit inactive later. * fix(api/v2): no duplicate WORKFLOW job row on durable background runs A v2 background run created TWO JobType.WORKFLOW rows for one flow execution: the durable row (submit()'s job_id, owned by JobRunner) plus an orphan keyed by the run_id generate_flow_events mints, because the build pipeline's track_job_status defaults True and the durable frame source never passed False. The flow ran once (double bookkeeping), but every background run left a phantom WORKFLOW row + job_events and double-fired the memory-base hook, skewing metrics. Thread track_job_status through _stream_event_frames; pass False only from the background frame source (the durable runner already owns the row + fires the hook with the durable job_id). Stream/public paths keep default True. Also gate build.py's memory-base hook fire behind track_job_status so background doesn't double-fire. Adds a regression test (RED before fix: found 2 rows). * test(api/v2): update stale workflow-stop tests for the durable design These 3 tests targeted the removed queue-service stop helper (_cancel_workflow_queue_job / get_queue_service), inherited via the agui->bg-default merge and failing with AttributeError across the stack: - test_stop_workflow_success: adapted to the durable stop path (revoke_task -> stop_job -> update_job_status(CANCELLED)). - test_stop_workflow_allowed_for_legacy_job_with_no_user_id (IDOR): adapted to the durable mechanism; still asserts the ownership check does not block a legacy user_id=None row. - test_stop_workflow_returns_503_when_queue_cancel_cannot_be_confirmed: dropped — the durable stop writes a best-effort STOP signal and always finalizes CANCELLED; the queue-service 'cannot confirm -> 503' path no longer exists. test_workflow.py now passes 24/24. * fix(api/v2): move FrameSourceFactory alias under TYPE_CHECKING As a module-level runtime value, FrameSourceFactory = Callable[..., Any] is a GenericAlias that passes isinstance(obj, type) but makes issubclass(obj, Service) raise on Python 3.10/3.14. The service factory scans this module for Service subclasses (services/factory.py:90), so the runtime alias crashed service initialization on those interpreters with 'issubclass() arg 1 must be a class' -> 'Could not initialize services', erroring out dozens of unrelated tests at setup (3.13 was unaffected, which is why local runs passed). The alias is only referenced in a lazy annotation (from __future__ import annotations), so moving it + the Callable import under TYPE_CHECKING removes it from the runtime namespace with no behavior change. Verified: alias absent from runtime module namespace, factory scan finds BackgroundExecutionService cleanly, durable service tests 26/26 pass. * test(lfx): register background-execution Settings fields in the field-count gate The durable background-execution work added six Settings fields (background_max_concurrency, background_job_timeout, background_lease_ttl_s, background_heartbeat_interval_s, background_watchdog_interval_s, test_redis_url) without updating EXPECTED_FIELDS, so test_field_count_unchanged failed 152 != 146. These are intentional bg-exec config; add them to the gate. * fix(api/v2): restore "end" side-channel event in AG-UI workflow stream The durable background-execution rewrite of workflow.py reverted `side_channel_events` to its pre-"end" form, dropping the "end" event from the AG-UI side-channel. That event carries `build_duration` to the playground chat-view, and the message metadata badge only renders when `hasDuration || hasTokens`. With build_duration gone the badge vanished, failing the token-usage and shareable-playground "Finished In" regression tests. Re-add "end" so the streaming playground path delivers it again. * fix(api/v2): apply request tweaks on the streaming and background paths The v2 workflows endpoint applied `tweaks` only on mode=sync. The stream and background paths build the graph via the v1 build-vertex loop (`generate_flow_events`), which never received the tweaks, so they were silently dropped. The confusing symptom: a model passed via tweaks surfaced as "A model selection is required", and any per-component override was ignored on non-sync runs. Thread `parsed.tweaks` into `generate_flow_events` and apply them to the built graph via `vertex.update_raw_params`. We do not use the lfx `process_tweaks_on_graph` helper because it only sets `vertex.params`, which does not persist to runtime (the same bug `lfx.base.tools.run_flow._process_tweaks_on_graph` works around). No-tweaks runs are unchanged (guarded by `if tweaks`). Adds a streaming regression test that overrides ChatInput via tweaks and asserts the value drives the run. * fix(api/v2): return background run output from completed status A completed background run's GET status returned a bare COMPLETED with an empty `outputs` and a null `output`. The COMPLETED branch reconstructs from `vertex_builds` keyed by job_id, which the durable path does not write, so reconstruction raised ValueError and fell through to an empty response; the result was only retrievable via a /events re-attach. The runner now captures the terminal `output` events (the langflow adapter's normalized ComponentOutput payloads) into `Job.result`, and the status COMPLETED branch rebuilds the `outputs` map and resolved `output` from them via `workflow_response_from_output_events`, matching the sync response. agui-protocol runs emit no `output` events, so their status stays result-less (the result remains on the /events log). * fix(api/v2): address review findings on the v2 workflows endpoint - recover session_id for completed background jobs from the persisted terminal message instead of always returning null, so GET status can continue the same chat/memory thread - replay a user-cancel as a CUSTOM cancel marker + RUN_FINISHED (agui) and a `cancelled` terminal (langflow) instead of RUN_ERROR, so a re-attaching client no longer reads a deliberate stop as a failure - cancel the evicted still-running buffer writer when the background-run registry is full, so it stops appending into a run no reader can find - derive per-component status from the error artifact / valid flag instead of hardcoding COMPLETED, and stop the langflow adapter dropping `valid` - throttle the unauthenticated public endpoint per IP and bound its input_value/session_id length - document the sync-only scope of request-body globals - document that live event re-attach is intentionally owner-only * test(lfx): register public_flow_rate_limit_per_minute in settings composition * refactor(v2 workflows): split workflow.py and address review blockers Splits the ~1.5k-line workflow.py into focused modules and folds in the execution-timeout and error-sanitization fixes from Cristhianzl's review of #13307. - B1: workflow.py now holds only the four route handlers. Validation guards move to workflow_validation, the sync/stream run loop to workflow_execution, and the durable background machinery to workflow_background (layered, acyclic). - I1: add workflow_execution_timeout (default 300) and apply a single wall-clock ceiling across sync, stream, background, and public via _stream_event_frames. A timeout becomes a sanitized terminal error and marks a background job failed. - I3: the route error handlers no longer echo raw exception text. They return a generic, code-tagged message and log the full exception server-side. - R1: remove the "commented out / future scope" comments that sat over live dataframe-extraction code in converters.py. - R4: drop the worker-routing internals from the reattach 409 message. Tests cover the timeout terminal-error path and the error-body sanitization, and the settings field-count guard is updated for the new setting. * refactor(lfx): extract v2 workflow contract layer into lfx.workflow Moves the protocol-agnostic pieces of the v2 workflows API out of the langflow backend into lfx so both the backend and `lfx serve` can share one contract. First step toward giving lfx (the production runtime) the v2 workflows API. - Move api/v2/adapters/, agui_translator.py, and converters.py to lfx/workflow/. They depend only on lfx.schema.workflow and ag_ui (already an lfx dep), so lfx carries the contract with zero langflow imports. - Decouple the one langflow reference: converters typed run_response against langflow.api.v1.schemas.RunResponse (TYPE_CHECKING only). Replaced with a local RunResponseLike Protocol (outputs + session_id), the only attributes used. - Repoint the six backend v2 workflow modules to import from lfx.workflow. - Move the five protocol-agnostic contract tests into src/lfx/tests/unit/workflow/ (run in the lfx-only env). test_output_event_parity and test_workflow_agui stay in langflow (they need langflow.api.build) with repointed imports. Coverage unchanged: 201 contract tests pass in the lfx-only env, 191 backend v2 tests pass; 392 total, same as before the move. * fix(background-execution): prevent worker deadlock on stop() under Python 3.10 The bounded executor's worker awaited the in-flight job task with a bare `await task`. On Python 3.10, when stop() cancels a worker while its job task is finishing, the awaiter's wakeup is lost and the event loop idles forever in select(), deadlocking stop(). Await via a done-callback Event (the same mechanism stop()'s own asyncio.gather already uses), which delivers the wakeup reliably; task.result() preserves the cancellation and exception semantics of the bare await. * fix(background-execution): keep ephemeral frames on reattach, pin psycopg in real-service tests The real-service CI job broke on `ModuleNotFoundError: No module named 'asyncpg'`. asyncpg was never declared anywhere in this repo; it arrived transitively through `cuga`, which release-1.11.0 dropped when it moved the root dep to `lfx-bundles[all-no-torch]` (#13886). Normalize the harness URL to `postgresql+psycopg` instead: it is what `--extra postgresql` installs and what DatabaseService already selects for async Postgres, so the test now exercises the production driver rather than a stowaway. Also fix a real reattach bug. The runner publishes ephemeral token frames tagged with the last durable seq (they have no job_events row), while reattach's tail skipped anything with `seq <= highest`. After replay left `highest` at that same seq, every token delta was dropped until the next durable milestone advanced it, so a reconnect mid-stream saw no tokens. Mark frames durable/ephemeral and dedupe only the durable ones, which are the only frames a replay can return. Log instead of silently suppressing a failed stop_job signal, and drop a comment block duplicated verbatim in InProcessExecutor.stop(). * fix(migrations): merge alembic heads (mcp_server + execution_signals) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 266617b commit 7025c44

67 files changed

Lines changed: 6454 additions & 417 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/migration-validation.yml

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ on:
88
- 'src/backend/base/langflow/services/database/service.py'
99
- 'src/backend/tests/unit/alembic/**'
1010
- '.github/workflows/migration-validation.yml'
11+
- 'src/backend/base/langflow/services/background_execution/**'
12+
- 'src/backend/base/langflow/services/jobs/**'
13+
- 'src/backend/base/langflow/services/job_queue/**'
14+
- 'src/backend/tests/unit/background_execution/**'
1115

1216
jobs:
1317
model-migration-consistency:
@@ -19,7 +23,7 @@ jobs:
1923
image: postgres:16
2024
env:
2125
POSTGRES_USER: langflow
22-
POSTGRES_PASSWORD: langflow
26+
POSTGRES_PASSWORD: langflow # pragma: allowlist secret
2327
POSTGRES_DB: langflow
2428
ports:
2529
- 5432:5432
@@ -50,10 +54,63 @@ jobs:
5054
- name: Check model/migration consistency
5155
env:
5256
MIGRATION_VALIDATION_CI: "true"
53-
LANGFLOW_TEST_DATABASE_URI: "postgresql://langflow:langflow@localhost:5432/langflow"
57+
LANGFLOW_TEST_DATABASE_URI: "postgresql://langflow:langflow@localhost:5432/langflow" # pragma: allowlist secret
5458
run: |
5559
uv run pytest src/backend/tests/unit/alembic/test_migration_execution.py -x -v
5660
61+
background-real-service:
62+
name: Background Execution Real-Service Tests (real Postgres + Redis)
63+
runs-on: ubuntu-latest
64+
65+
services:
66+
postgres:
67+
image: postgres:16
68+
env:
69+
POSTGRES_USER: langflow
70+
POSTGRES_PASSWORD: langflow # pragma: allowlist secret
71+
POSTGRES_DB: langflow
72+
ports:
73+
- 5432:5432
74+
options: >-
75+
--health-cmd="pg_isready -U langflow"
76+
--health-interval=10s
77+
--health-timeout=5s
78+
--health-retries=5
79+
redis:
80+
image: redis:7
81+
ports:
82+
- 6379:6379
83+
options: >-
84+
--health-cmd="redis-cli ping"
85+
--health-interval=10s
86+
--health-timeout=5s
87+
--health-retries=5
88+
89+
steps:
90+
- name: Checkout code
91+
uses: actions/checkout@v6
92+
with:
93+
fetch-depth: 0
94+
95+
- name: Install uv
96+
uses: astral-sh/setup-uv@v6
97+
98+
- name: Setup Python
99+
uses: actions/setup-python@v6
100+
with:
101+
python-version: '3.12'
102+
103+
- name: Install dependencies
104+
run: |
105+
uv sync --extra postgresql
106+
107+
- name: Run real-service tests
108+
env:
109+
LANGFLOW_TEST_DATABASE_URI: "postgresql://langflow:langflow@localhost:5432/langflow" # pragma: allowlist secret
110+
LANGFLOW_TEST_REDIS_URL: "redis://localhost:6379/0"
111+
run: |
112+
uv run pytest src/backend/tests/unit/background_execution -m real_services -x -v
113+
57114
validate-migration:
58115
name: Migration Pattern Validation
59116
runs-on: ubuntu-latest

.secrets.baseline

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -153,24 +153,6 @@
153153
"is_secret": false
154154
}
155155
],
156-
".github/workflows/migration-validation.yml": [
157-
{
158-
"type": "Secret Keyword",
159-
"filename": ".github/workflows/migration-validation.yml",
160-
"hashed_secret": "e80c4f90316c87b6b24d03890493c8d1c7c1c99d",
161-
"is_verified": false,
162-
"line_number": 22,
163-
"is_secret": false
164-
},
165-
{
166-
"type": "Basic Auth Credentials",
167-
"filename": ".github/workflows/migration-validation.yml",
168-
"hashed_secret": "e80c4f90316c87b6b24d03890493c8d1c7c1c99d",
169-
"is_verified": false,
170-
"line_number": 53,
171-
"is_secret": false
172-
}
173-
],
174156
".github/workflows/nightly_build.yml": [
175157
{
176158
"type": "Secret Keyword",
@@ -1175,15 +1157,15 @@
11751157
"filename": "src/backend/tests/conftest.py",
11761158
"hashed_secret": "8bb6118f8fd6935ad0876a3be34a717d32708ffd",
11771159
"is_verified": false,
1178-
"line_number": 555,
1160+
"line_number": 559,
11791161
"is_secret": false
11801162
},
11811163
{
11821164
"type": "Secret Keyword",
11831165
"filename": "src/backend/tests/conftest.py",
11841166
"hashed_secret": "61fbb5a12cd7b1f1fe1624120089efc0cd299e43",
11851167
"is_verified": false,
1186-
"line_number": 765,
1168+
"line_number": 769,
11871169
"is_secret": false
11881170
}
11891171
],

Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ unit_tests: ## run unit tests
168168
unit_tests_looponfail:
169169
@make unit_tests args="-f"
170170

171+
real_services_tests: ## run tests that need real service instances (needs LANGFLOW_TEST_DATABASE_URI + LANGFLOW_TEST_REDIS_URL)
172+
@uv sync --frozen
173+
uv run pytest src/backend/tests/unit \
174+
--ignore=src/backend/tests/integration \
175+
--ignore=src/backend/tests/unit/template \
176+
-m real_services -ra $(args)
177+
171178
lfx_tests: ## run lfx package unit tests
172179
@echo 'Running LFX Package Tests...'
173180
@cd src/lfx && \

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,8 @@ markers = [
270270
"unit: Unit tests",
271271
"integration: Integration tests",
272272
"slow: Slow-running tests",
273-
"security: Security regression tests (IDOR, auth, access control)"
273+
"security: Security regression tests (IDOR, auth, access control)",
274+
"real_services: Tests that need real service instances (real SQLite + real Postgres + real Redis)"
274275
]
275276
asyncio_mode = "auto"
276277
asyncio_default_fixture_loop_scope = "function"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""add result and error columns to job table.
2+
3+
Revision ID: 185482a2d715
4+
Revises: b7c4d8e9f012
5+
Create Date: 2026-06-03 10:00:00.000000
6+
7+
Phase: EXPAND
8+
"""
9+
10+
from collections.abc import Sequence
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
from sqlalchemy.dialects import postgresql
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "185482a2d715" # pragma: allowlist secret
18+
down_revision: str | None = "c3e7a1b9d2f4" # pragma: allowlist secret
19+
branch_labels: str | Sequence[str] | None = None
20+
depends_on: str | Sequence[str] | None = None
21+
22+
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
23+
24+
25+
def upgrade() -> None:
26+
conn = op.get_bind()
27+
existing_columns = {col["name"] for col in sa.inspect(conn).get_columns("job")}
28+
29+
with op.batch_alter_table("job", schema=None) as batch_op:
30+
if "result" not in existing_columns:
31+
batch_op.add_column(sa.Column("result", _JSON, nullable=True))
32+
if "error" not in existing_columns:
33+
batch_op.add_column(sa.Column("error", _JSON, nullable=True))
34+
35+
36+
def downgrade() -> None:
37+
conn = op.get_bind()
38+
existing_columns = {col["name"] for col in sa.inspect(conn).get_columns("job")}
39+
40+
with op.batch_alter_table("job", schema=None) as batch_op:
41+
if "error" in existing_columns:
42+
batch_op.drop_column("error")
43+
if "result" in existing_columns:
44+
batch_op.drop_column("result")
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""add execution_signals table for cooperative job control.
2+
3+
Revision ID: 8ce44e4858c6
4+
Revises: b026885b89c8
5+
Create Date: 2026-06-03 10:10:00.000000
6+
7+
Phase: EXPAND
8+
"""
9+
10+
from collections.abc import Sequence
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
from sqlalchemy.dialects import postgresql
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "8ce44e4858c6" # pragma: allowlist secret
18+
down_revision: str | None = "b026885b89c8" # pragma: allowlist secret
19+
branch_labels: str | Sequence[str] | None = None
20+
depends_on: str | Sequence[str] | None = None
21+
22+
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
23+
24+
25+
def upgrade() -> None:
26+
from langflow.utils import migration
27+
28+
conn = op.get_bind()
29+
if not migration.table_exists("execution_signals", conn):
30+
op.create_table(
31+
"execution_signals",
32+
sa.Column("id", sa.Uuid(), nullable=False),
33+
sa.Column("job_id", sa.Uuid(), nullable=False),
34+
sa.Column(
35+
"signal_type",
36+
sa.Enum("stop", name="execution_signal_type_enum"),
37+
nullable=False,
38+
),
39+
sa.Column("data", _JSON, nullable=True),
40+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
41+
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
42+
sa.PrimaryKeyConstraint("id"),
43+
)
44+
with op.batch_alter_table("execution_signals", schema=None) as batch_op:
45+
batch_op.create_index(batch_op.f("ix_execution_signals_id"), ["id"], unique=False)
46+
batch_op.create_index(batch_op.f("ix_execution_signals_job_id"), ["job_id"], unique=False)
47+
48+
49+
def downgrade() -> None:
50+
from langflow.utils import migration
51+
52+
conn = op.get_bind()
53+
if migration.table_exists("execution_signals", conn):
54+
op.drop_table("execution_signals")
55+
# Drop the postgres enum type explicitly; sqlite has no standalone enum type.
56+
bind = op.get_bind()
57+
if bind.dialect.name == "postgresql":
58+
sa.Enum(name="execution_signal_type_enum").drop(bind, checkfirst=True)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""merge_mcp_and_execution_signals_heads
2+
3+
Revision ID: 9d5e24d777bf
4+
Revises: 247308ce2598, 8ce44e4858c6
5+
Create Date: 2026-07-10 12:00:00.000000
6+
7+
Phase: EXPAND
8+
"""
9+
10+
from collections.abc import Sequence
11+
12+
# revision identifiers, used by Alembic.
13+
revision: str = "9d5e24d777bf" # pragma: allowlist secret
14+
down_revision: str | Sequence[str] | None = ("247308ce2598", "8ce44e4858c6") # pragma: allowlist secret
15+
branch_labels: str | Sequence[str] | None = None
16+
depends_on: str | Sequence[str] | None = None
17+
18+
19+
def upgrade() -> None:
20+
pass
21+
22+
23+
def downgrade() -> None:
24+
pass
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""add job_events table for durable background-job event log.
2+
3+
Revision ID: b026885b89c8
4+
Revises: 185482a2d715
5+
Create Date: 2026-06-03 10:05:00.000000
6+
7+
Phase: EXPAND
8+
"""
9+
10+
from collections.abc import Sequence
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
from sqlalchemy.dialects import postgresql
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "b026885b89c8" # pragma: allowlist secret
18+
down_revision: str | None = "185482a2d715" # pragma: allowlist secret
19+
branch_labels: str | Sequence[str] | None = None
20+
depends_on: str | Sequence[str] | None = None
21+
22+
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
23+
24+
25+
def upgrade() -> None:
26+
from langflow.utils import migration
27+
28+
conn = op.get_bind()
29+
if not migration.table_exists("job_events", conn):
30+
op.create_table(
31+
"job_events",
32+
sa.Column("id", sa.Uuid(), nullable=False),
33+
sa.Column("job_id", sa.Uuid(), nullable=False),
34+
sa.Column("seq", sa.Integer(), nullable=False),
35+
sa.Column("event_type", sa.String(), nullable=False),
36+
sa.Column("payload", _JSON, nullable=True),
37+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
38+
sa.PrimaryKeyConstraint("id"),
39+
sa.UniqueConstraint("job_id", "seq", name="uq_job_events_job_id_seq"),
40+
)
41+
with op.batch_alter_table("job_events", schema=None) as batch_op:
42+
batch_op.create_index(batch_op.f("ix_job_events_id"), ["id"], unique=False)
43+
batch_op.create_index(batch_op.f("ix_job_events_job_id"), ["job_id"], unique=False)
44+
45+
46+
def downgrade() -> None:
47+
from langflow.utils import migration
48+
49+
conn = op.get_bind()
50+
if migration.table_exists("job_events", conn):
51+
op.drop_table("job_events")

0 commit comments

Comments
 (0)