Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ DATABASE_NAME=baserow
# BASEROW_AUTOMATION_WORKFLOW_HISTORY_MAX_ENTRIES=
# BASEROW_AUTOMATION_WORKFLOW_HISTORY_MIN_RETENTION_DAYS=
# BASEROW_AUTOMATION_WORKFLOW_HISTORY_CLEANUP_INTERVAL_MINUTES=
# BASEROW_AUTOMATION_MAX_NODE_DISPATCHES_PER_RUN=
# BASEROW_EXTRA_ALLOWED_HOSTS=
# ADDITIONAL_APPS=
# ADDITIONAL_MODULES=
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
dockerfiles: ${{ steps.filter.outputs.dockerfiles }}
e2e: ${{ steps.filter.outputs.e2e }}
mjml: ${{ steps.filter.outputs.mjml }}
zapier: ${{ steps.filter.outputs.zapier }}
helm: ${{ steps.filter.outputs.helm }}
Expand All @@ -236,6 +237,9 @@ jobs:
dockerfiles:
- '**/Dockerfile'
- '.github/workflows/ci.yml'
e2e:
- 'e2e-tests/**'
- '.github/workflows/ci.yml'
mjml:
- '**/*.eta'
- '.github/workflows/ci.yml'
Expand Down Expand Up @@ -673,7 +677,7 @@ jobs:
- detect-changes
- build-backend
- build-frontend
if: needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.frontend == 'true' || needs.detect-changes.outputs.dockerfiles == 'true' || github.ref_name == 'develop' || github.ref_name == 'master'
if: needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.frontend == 'true' || needs.detect-changes.outputs.dockerfiles == 'true' || needs.detect-changes.outputs.e2e == 'true' || github.ref_name == 'develop' || github.ref_name == 'master'
permissions:
contents: read
packages: read
Expand Down Expand Up @@ -866,6 +870,10 @@ jobs:
PUBLIC_BACKEND_URL: http://localhost:8000
PUBLIC_WEB_FRONTEND_URL: http://localhost:3000
PRIVATE_BACKEND_URL: http://backend:8000
# The CI web-frontend runs with the default (empty) cookie prefix, so
# tests that read/set cookies (e.g. the user_source_token) must use the
# same empty prefix rather than playwright.config's baserow_e2e_ default.
BASEROW_FRONTEND_COOKIE_PREFIX: ""
CI: 1
run: |
cd e2e-tests
Expand Down
5 changes: 5 additions & 0 deletions backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,11 @@ def __setitem__(self, key, value):
AUTOMATION_WORKFLOW_HISTORY_CLEANUP_INTERVAL_MINUTES = int(
os.getenv("BASEROW_AUTOMATION_WORKFLOW_HISTORY_CLEANUP_INTERVAL_MINUTES", 60)
)
# The maximum number of node dispatches allowed in a single workflow run.
# This protects against infinite dispatches due to a misconfigured node.
AUTOMATION_MAX_NODE_DISPATCHES_PER_RUN = int(
os.getenv("BASEROW_AUTOMATION_MAX_NODE_DISPATCHES_PER_RUN", 1000)
)

TRASH_PAGE_SIZE_LIMIT = 200 # How many trash entries can be requested at once.

Expand Down
18 changes: 18 additions & 0 deletions backend/src/baserow/contrib/automation/api/history/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class AutomationNodeHistorySerializer(serializers.ModelSerializer):
iteration = serializers.SerializerMethodField()
iteration_path = serializers.SerializerMethodField()
edge_label = serializers.SerializerMethodField()
destination_node_id = serializers.SerializerMethodField()
destination_node_type = serializers.SerializerMethodField()
destination_label = serializers.SerializerMethodField()

class Meta:
model = AutomationNodeHistory
Expand All @@ -32,6 +35,9 @@ class Meta:
"iteration",
"iteration_path",
"edge_label",
"destination_node_id",
"destination_node_type",
"destination_label",
)

@extend_schema_field(OpenApiTypes.STR)
Expand Down Expand Up @@ -71,6 +77,18 @@ def get_iteration_path(self, obj):
def get_edge_label(self, obj):
return self.context.get("edge_labels", {}).get(obj.id, "")

@extend_schema_field(OpenApiTypes.INT)
def get_destination_node_id(self, obj):
return self.context.get("destinations", {}).get(obj.id, {}).get("id")

@extend_schema_field(OpenApiTypes.STR)
def get_destination_node_type(self, obj):
return self.context.get("destinations", {}).get(obj.id, {}).get("type", "")

@extend_schema_field(OpenApiTypes.STR)
def get_destination_label(self, obj):
return self.context.get("destinations", {}).get(obj.id, {}).get("label", "")


class AutomationNodeResultSerializer(serializers.ModelSerializer):
class Meta:
Expand Down
6 changes: 5 additions & 1 deletion backend/src/baserow/contrib/automation/api/history/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,14 @@ def get(self, request, workflow_history_id: int):
service = AutomationHistoryService()
node_histories = service.get_node_histories(request.user, workflow_history_id)
edge_labels = service.get_edge_labels(request.user, node_histories)
destinations = service.get_destination_labels(request.user, node_histories)
serializer = AutomationNodeHistorySerializer(
node_histories,
many=True,
context={"edge_labels": edge_labels},
context={
"edge_labels": edge_labels,
"destinations": destinations,
},
)
return Response(serializer.data)

Expand Down
2 changes: 2 additions & 0 deletions backend/src/baserow/contrib/automation/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def ready(self):
from baserow.contrib.automation.nodes.node_types import (
AIAgentActionNodeType,
CoreCSVFileReaderNodeType,
CoreGotoActionNodeType,
CoreHttpRequestNodeType,
CoreHTTPTriggerNodeType,
CoreIteratorNodeType,
Expand Down Expand Up @@ -186,6 +187,7 @@ def ready(self):
automation_node_type_registry.register(CoreSMTPEmailNodeType())
automation_node_type_registry.register(CoreRouterActionNodeType())
automation_node_type_registry.register(CoreStartWorkflowNodeType())
automation_node_type_registry.register(CoreGotoActionNodeType())
automation_node_type_registry.register(LocalBaserowRowsCreatedNodeTriggerType())
automation_node_type_registry.register(LocalBaserowRowsUpdatedNodeTriggerType())
automation_node_type_registry.register(LocalBaserowRowsDeletedNodeTriggerType())
Expand Down
3 changes: 3 additions & 0 deletions backend/src/baserow/contrib/automation/history/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ class HistoryStatusChoices(models.TextChoices):
ERROR = "error"
DISABLED = "disabled"
STARTED = "started"
# The node ran without doing anything, e.g. a "Go to node" whose
# condition resolved to false, so no jump was followed.
SKIPPED = "skipped"
61 changes: 57 additions & 4 deletions backend/src/baserow/contrib/automation/history/handler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Dict, Iterable, List, Optional, Union
from typing import Any, Dict, Iterable, List, Optional, Union

from django.db.models import Prefetch, QuerySet

Expand Down Expand Up @@ -126,15 +126,25 @@ def create_node_result(
def get_node_result(self, history, node, iteration_path):
"""
Returns the result for the given history/node/iteration_path.

A node can be dispatched several times within a single run when a jump
(e.g. the "Go to node" node) loops execution back to it. Each pass writes a
new result with the same iteration_path, so we return the most recent one
rather than assuming a single result exists.
"""

try:
node_result = AutomationNodeResult.objects.only("result").get(
node_result = (
AutomationNodeResult.objects.only("result")
.filter(
node_history__workflow_history_id=history.id,
node_history__node_id=node.id,
iteration_path=iteration_path,
)
except AutomationNodeResult.DoesNotExist:
.order_by("-node_history__started_on", "-node_history_id")
.first()
)

if node_result is None:
raise AutomationWorkflowHistoryNodeResultDoesNotExist()

return node_result.result
Expand Down Expand Up @@ -271,3 +281,46 @@ def get_edge_labels(
for nr in results
if (label := nr.result.get("edge", {}).get("label"))
}

def get_destination_labels(
self, node_histories: List[AutomationNodeHistory]
) -> Dict[int, Dict[str, Any]]:
"""
For each history entry whose node redirected execution to another node
(e.g. the "Go to node" node), return a mapping that describes the node
it jumped to, e.g.:
{
"id": <destination node id>,
"type": <destination node type>,
"label": <destination label>,
}`

The destination node belongs to the published workflow copy that
actually ran, so its id cannot be resolved against the editor workflow.
We therefore also return its type, allowing the frontend to display a
human-readable name from the node type registry when the node has no
custom label.
"""

# A node can be dispatched many times within a single run when a jump
# loops execution back to it, so we resolve each distinct node only
# once. Resolving a destination costs several queries, and the result
# only depends on the node, not on the individual dispatch.
resolved: Dict[int, Optional[Dict[str, Any]]] = {}
labels = {}
for nh in node_histories:
node = nh.node
if node.id not in resolved:
destination = node.get_type().get_history_destination_node(node)
resolved[node.id] = (
{
"id": destination.id,
"type": destination.get_type().type,
"label": destination.label,
}
if destination is not None
else None
)
if (destination_label := resolved[node.id]) is not None:
labels[nh.id] = destination_label
return labels
14 changes: 13 additions & 1 deletion backend/src/baserow/contrib/automation/history/service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Dict, List
from typing import Any, Dict, List

from django.contrib.auth.models import AbstractUser
from django.db.models import QuerySet
Expand Down Expand Up @@ -66,3 +66,15 @@ def get_edge_labels(
workflow = node_histories[0].workflow_history.original_workflow
self._check_workflow_permissions(user, workflow)
return self.handler.get_edge_labels(node_histories)

def get_destination_labels(
self,
user: AbstractUser,
node_histories: List[AutomationNodeHistory],
) -> Dict[int, Dict[str, Any]]:
if not node_histories:
return {}

workflow = node_histories[0].workflow_history.original_workflow
self._check_workflow_permissions(user, workflow)
return self.handler.get_destination_labels(node_histories)
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.2.14 on 2026-06-24 09:14

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('automation', '0034_corestartworkflowactionnode'),
]

operations = [
migrations.CreateModel(
name='CoreGotoActionNode',
fields=[
('automationnode_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='automation.automationnode')),
],
options={
'abstract': False,
},
bases=('automation.automationnode',),
),
migrations.AlterField(
model_name='automationnodehistory',
name='status',
field=models.CharField(choices=[('success', 'Success'), ('error', 'Error'), ('disabled', 'Disabled'), ('started', 'Started'), ('skipped', 'Skipped')], max_length=8),
),
migrations.AlterField(
model_name='automationworkflowhistory',
name='status',
field=models.CharField(choices=[('success', 'Success'), ('error', 'Error'), ('disabled', 'Disabled'), ('started', 'Started'), ('skipped', 'Skipped')], max_length=8),
),
]
13 changes: 12 additions & 1 deletion backend/src/baserow/contrib/automation/nodes/actions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any

from django.contrib.auth.models import AbstractUser
Expand All @@ -10,6 +10,7 @@
)
from baserow.contrib.automation.nodes.models import AutomationActionNode, AutomationNode
from baserow.contrib.automation.nodes.node_types import AutomationNodeType
from baserow.contrib.automation.nodes.registries import automation_node_type_registry
from baserow.contrib.automation.nodes.service import AutomationNodeService
from baserow.contrib.automation.nodes.trash_types import AutomationNodeTrashableItemType
from baserow.contrib.automation.workflows.models import AutomationWorkflow
Expand Down Expand Up @@ -392,6 +393,9 @@ class Params:
destination_reference_node_id: int
destination_position: GraphPointPositionType
destination_output: str
# Reversible modifications node types made to reconcile the workflow
# after the move, keyed by node type. Reverted on undo.
move_extra_data: dict = field(default_factory=dict)

@classmethod
def do(
Expand Down Expand Up @@ -425,6 +429,7 @@ def do(
reference_node_id,
position,
output,
move.move_extra_data,
),
scope=cls.scope(workflow.id),
workspace=workflow.automation.workspace,
Expand All @@ -449,6 +454,12 @@ def undo(
params.origin_position,
params.origin_output,
)
# The node is back at its original level, so revert the reconciliations
# the move made (e.g. restore Go to links it cleared).
for node_type_str, modifications in params.move_extra_data.items():
automation_node_type_registry.get(node_type_str).revert_move(
user, modifications
)

@classmethod
def redo(
Expand Down
7 changes: 7 additions & 0 deletions backend/src/baserow/contrib/automation/nodes/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,10 @@ class AutomationNodeMissingOutput(AutomationNodeError):
"""
Raised when the target output is missing in the reference node.
"""


class AutomationNodeMaxDispatchesExceeded(AutomationNodeError):
"""
Raised when a workflow run exceeds the maximum number of node dispatches,
a safety backstop against infinite loops.
"""
Loading
Loading