mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
auto/execu
...
auto/data-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215e769735 | ||
|
|
03a816333b | ||
|
|
54433c5dae | ||
|
|
9269d045c8 | ||
|
|
29d8990263 | ||
|
|
0c9af8290f |
133
enterprise/migrations/versions/100_create_automation_tables.py
Normal file
133
enterprise/migrations/versions/100_create_automation_tables.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Create automation tables (automations, automation_events, automation_runs)
|
||||
|
||||
Revision ID: 100
|
||||
Revises: 099
|
||||
Create Date: 2025-03-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '100'
|
||||
down_revision: Union[str, None] = '099'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- automation_events (must come first, referenced by automation_runs) ---
|
||||
op.create_table(
|
||||
'automation_events',
|
||||
sa.Column('id', sa.BigInteger(), sa.Identity(), nullable=False, primary_key=True),
|
||||
sa.Column('source_type', sa.String(), nullable=False),
|
||||
sa.Column('payload', sa.JSON(), nullable=False),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('dedup_key', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False, server_default=sa.text("'NEW'")),
|
||||
sa.Column('error_detail', sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('dedup_key', name='uq_automation_events_dedup'),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_automation_events_new',
|
||||
'automation_events',
|
||||
['created_at'],
|
||||
postgresql_where=sa.text("status = 'NEW'"),
|
||||
)
|
||||
|
||||
# --- automations ---
|
||||
op.create_table(
|
||||
'automations',
|
||||
sa.Column('id', sa.String(), nullable=False, primary_key=True),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('org_id', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
|
||||
sa.Column('config', sa.JSON(), nullable=False),
|
||||
sa.Column('trigger_type', sa.String(), nullable=False),
|
||||
sa.Column('file_store_key', sa.String(), nullable=False),
|
||||
sa.Column('last_triggered_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.Column(
|
||||
'updated_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_automations_user_id', 'automations', ['user_id'])
|
||||
op.create_index('ix_automations_org_id', 'automations', ['org_id'])
|
||||
op.create_index('ix_automations_enabled_trigger', 'automations', ['enabled', 'trigger_type'])
|
||||
|
||||
# --- automation_runs ---
|
||||
op.create_table(
|
||||
'automation_runs',
|
||||
sa.Column('id', sa.String(), nullable=False, primary_key=True),
|
||||
sa.Column(
|
||||
'automation_id',
|
||||
sa.String(),
|
||||
sa.ForeignKey('automations.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'event_id',
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey('automation_events.id'),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column('conversation_id', sa.String(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=False, server_default=sa.text("'PENDING'")),
|
||||
sa.Column('claimed_by', sa.String(), nullable=True),
|
||||
sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('heartbeat_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('retry_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('max_retries', sa.Integer(), nullable=False, server_default=sa.text('3')),
|
||||
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('event_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('error_detail', sa.Text(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_automation_runs_automation_id', 'automation_runs', ['automation_id'])
|
||||
op.create_index(
|
||||
'ix_automation_runs_claimable',
|
||||
'automation_runs',
|
||||
['status', 'next_retry_at'],
|
||||
postgresql_where=sa.text("status = 'PENDING' AND (next_retry_at IS NULL OR next_retry_at <= now())"),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_automation_runs_heartbeat',
|
||||
'automation_runs',
|
||||
['heartbeat_at'],
|
||||
postgresql_where=sa.text("status = 'RUNNING'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('automation_runs')
|
||||
op.drop_table('automations')
|
||||
op.drop_table('automation_events')
|
||||
36
enterprise/poetry.lock
generated
36
enterprise/poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
@@ -1641,6 +1641,22 @@ files = [
|
||||
{file = "crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "croniter"
|
||||
version = "6.0.0"
|
||||
description = "croniter provides iteration for datetime object with cron like format"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368"},
|
||||
{file = "croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
python-dateutil = "*"
|
||||
pytz = ">2021.1"
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
@@ -3501,7 +3517,7 @@ files = [
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.5.5"
|
||||
grpcio = ">=1.71.2"
|
||||
protobuf = ">=5.26.1,<6.0dev"
|
||||
protobuf = ">=5.26.1,<6.0.dev0"
|
||||
|
||||
[[package]]
|
||||
name = "gspread"
|
||||
@@ -3819,7 +3835,7 @@ pfzy = ">=0.3.1,<0.4.0"
|
||||
prompt-toolkit = ">=3.0.1,<4.0.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "installer"
|
||||
@@ -4258,7 +4274,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
referencing = ">=0.28.4"
|
||||
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
@@ -4648,7 +4664,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.05.14"
|
||||
certifi = ">=14.5.14"
|
||||
durationpy = ">=0.7"
|
||||
google-auth = ">=1.0.1"
|
||||
oauthlib = ">=3.2.2"
|
||||
@@ -6889,7 +6905,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pg8000"
|
||||
@@ -12866,10 +12882,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "scantree"
|
||||
@@ -15006,9 +15022,9 @@ files = [
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
|
||||
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "ef037f6d6085d26166d35c56ce266439f8f1a4fea90bc43ccf15cfeaf116cae5"
|
||||
content-hash = "d07fdf5fbc8eaf4ed30c119b0c05081d0eb3df60732e530002db5eec84ef080b"
|
||||
|
||||
@@ -17,6 +17,7 @@ packages = [
|
||||
{ include = "storage" },
|
||||
{ include = "sync" },
|
||||
{ include = "integrations" },
|
||||
{ include = "services" },
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
@@ -49,6 +50,7 @@ pandas = "^2.2.0"
|
||||
numpy = "^2.2.0"
|
||||
mcp = "^1.10.0"
|
||||
pillow = "^12.1.1"
|
||||
croniter = "^6.0.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.8.3"
|
||||
|
||||
0
enterprise/services/__init__.py
Normal file
0
enterprise/services/__init__.py
Normal file
121
enterprise/services/automation_config.py
Normal file
121
enterprise/services/automation_config.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Automation config extraction and validation.
|
||||
|
||||
Parses ``__config__`` from automation Python source files
|
||||
and validates it against a Pydantic schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from croniter import croniter
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
|
||||
def extract_config(source: str) -> dict:
|
||||
"""Extract the ``__config__`` dict from automation Python source code.
|
||||
|
||||
Uses :mod:`ast` to safely parse the source and locate a module-level
|
||||
assignment to ``__config__``. The value must be a literal expression
|
||||
(evaluated via :func:`ast.literal_eval`).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``__config__`` is not found or its value contains
|
||||
non-literal expressions.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError as exc:
|
||||
raise ValueError(f'Failed to parse source: {exc}') from exc
|
||||
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == '__config__':
|
||||
try:
|
||||
value = ast.literal_eval(node.value)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError(
|
||||
f'__config__ value must be a literal expression: {exc}'
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError('__config__ must be a dict')
|
||||
return value
|
||||
# Handle annotated assignment: __config__: dict = {...}
|
||||
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
if node.target.id == '__config__' and node.value is not None:
|
||||
try:
|
||||
value = ast.literal_eval(node.value)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError(
|
||||
f'__config__ value must be a literal expression: {exc}'
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError('__config__ must be a dict')
|
||||
return value
|
||||
|
||||
raise ValueError('__config__ not found in source')
|
||||
|
||||
|
||||
class CronTriggerModel(BaseModel):
|
||||
"""Cron trigger configuration."""
|
||||
|
||||
schedule: str
|
||||
timezone: str = 'UTC'
|
||||
|
||||
@field_validator('schedule')
|
||||
@classmethod
|
||||
def validate_schedule(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not croniter.is_valid(v):
|
||||
raise ValueError(f'Invalid cron expression: {v!r}')
|
||||
return v
|
||||
|
||||
@field_validator('timezone')
|
||||
@classmethod
|
||||
def validate_timezone(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
try:
|
||||
ZoneInfo(v)
|
||||
except (ZoneInfoNotFoundError, KeyError):
|
||||
raise ValueError(f'Invalid timezone: {v!r}')
|
||||
return v
|
||||
|
||||
|
||||
class TriggersModel(BaseModel):
|
||||
"""Container for trigger definitions. Exactly one trigger must be set."""
|
||||
|
||||
cron: CronTriggerModel | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def exactly_one_trigger(self) -> TriggersModel:
|
||||
defined = [name for name in ('cron',) if getattr(self, name) is not None]
|
||||
if len(defined) != 1:
|
||||
raise ValueError(f'Exactly one trigger must be defined, got: {defined}')
|
||||
return self
|
||||
|
||||
|
||||
class AutomationConfigModel(BaseModel):
|
||||
"""Top-level automation config schema."""
|
||||
|
||||
name: str
|
||||
triggers: TriggersModel
|
||||
description: str = ''
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not (1 <= len(v) <= 200):
|
||||
raise ValueError('name must be between 1 and 200 characters')
|
||||
return v
|
||||
|
||||
|
||||
def validate_config(config: dict) -> AutomationConfigModel:
|
||||
"""Validate a ``__config__`` dict against the automation schema.
|
||||
|
||||
Returns the parsed :class:`AutomationConfigModel` or raises
|
||||
:class:`pydantic.ValidationError`.
|
||||
"""
|
||||
return AutomationConfigModel.model_validate(config)
|
||||
36
enterprise/services/automation_event_publisher.py
Normal file
36
enterprise/services/automation_event_publisher.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Publish automation events and notify listeners via PostgreSQL NOTIFY."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from storage.automation_event import AutomationEvent
|
||||
|
||||
|
||||
def publish_automation_event(
|
||||
session: Session,
|
||||
source_type: str,
|
||||
payload: dict,
|
||||
dedup_key: str,
|
||||
metadata: dict | None = None,
|
||||
) -> AutomationEvent:
|
||||
"""Create an :class:`AutomationEvent` and add it to the session.
|
||||
|
||||
The caller is responsible for committing (or flushing) the session.
|
||||
"""
|
||||
event = AutomationEvent(
|
||||
source_type=source_type,
|
||||
payload=payload,
|
||||
dedup_key=dedup_key,
|
||||
metadata_=metadata,
|
||||
)
|
||||
session.add(event)
|
||||
return event
|
||||
|
||||
|
||||
def pg_notify_new_event(session: Session, event_id: int) -> None:
|
||||
"""Send a PostgreSQL ``NOTIFY`` on the ``automation_events`` channel."""
|
||||
session.execute(
|
||||
text("SELECT pg_notify('automation_events', :event_id)"),
|
||||
{'event_id': str(event_id)},
|
||||
)
|
||||
56
enterprise/services/automation_file_generator.py
Normal file
56
enterprise/services/automation_file_generator.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Generate automation Python files from user-provided parameters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
|
||||
|
||||
def generate_automation_file(
|
||||
name: str,
|
||||
schedule: str,
|
||||
timezone: str,
|
||||
prompt: str,
|
||||
) -> str:
|
||||
"""Return a complete, valid Python file string for an automation.
|
||||
|
||||
The generated file includes a ``__config__`` dict that can be round-tripped
|
||||
through :func:`services.automation_config.extract_config` and
|
||||
:func:`services.automation_config.validate_config`.
|
||||
"""
|
||||
# Use repr() for safe string escaping — handles backslashes, quotes, etc.
|
||||
r_name = repr(name)
|
||||
r_schedule = repr(schedule)
|
||||
r_timezone = repr(timezone)
|
||||
r_prompt = repr(prompt)
|
||||
|
||||
# Build a safe docstring — replace double quotes with single quotes to
|
||||
# prevent triple-quote breakage in the docstring.
|
||||
safe_docstring_name = name.replace('"', "'")
|
||||
|
||||
return textwrap.dedent(f'''\
|
||||
"""{safe_docstring_name} — auto-generated automation."""
|
||||
|
||||
__config__ = {{
|
||||
"name": {r_name},
|
||||
"triggers": {{
|
||||
"cron": {{
|
||||
"schedule": {r_schedule},
|
||||
"timezone": {r_timezone},
|
||||
}}
|
||||
}},
|
||||
}}
|
||||
|
||||
import os
|
||||
from openhands.sdk import LLM, Conversation
|
||||
from openhands.tools.preset.default import get_default_agent
|
||||
|
||||
llm = LLM(
|
||||
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
|
||||
api_key=os.getenv("LLM_API_KEY"),
|
||||
base_url=os.getenv("LLM_BASE_URL"),
|
||||
)
|
||||
agent = get_default_agent(llm=llm, cli_mode=True)
|
||||
conversation = Conversation(agent=agent, workspace=os.getcwd())
|
||||
conversation.send_message({r_prompt})
|
||||
conversation.run()
|
||||
''')
|
||||
109
enterprise/storage/automation.py
Normal file
109
enterprise/storage/automation.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.types import JSON
|
||||
from storage.base import Base
|
||||
|
||||
# Use JSON with JSONB variant so models work on SQLite (tests) and PostgreSQL (prod)
|
||||
_JsonType = JSON().with_variant(JSONB(), 'postgresql')
|
||||
|
||||
|
||||
class Automation(Base): # type: ignore
|
||||
"""Model for storing automation definitions."""
|
||||
|
||||
__tablename__ = 'automations'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
user_id = Column(String, nullable=False)
|
||||
org_id = Column(String, nullable=True)
|
||||
name = Column(String, nullable=False)
|
||||
enabled = Column(Boolean, nullable=False, server_default=text('TRUE'))
|
||||
config = Column(_JsonType, nullable=False)
|
||||
trigger_type = Column(String, nullable=False)
|
||||
file_store_key = Column(String, nullable=False)
|
||||
last_triggered_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=text('CURRENT_TIMESTAMP'),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=text('CURRENT_TIMESTAMP'),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
runs = relationship('AutomationRun', back_populates='automation', cascade='all, delete-orphan')
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_automations_user_id', 'user_id'),
|
||||
Index('ix_automations_org_id', 'org_id'),
|
||||
Index('ix_automations_enabled_trigger', 'enabled', 'trigger_type'),
|
||||
)
|
||||
|
||||
|
||||
class AutomationRun(Base): # type: ignore
|
||||
"""Model for storing automation run records."""
|
||||
|
||||
__tablename__ = 'automation_runs'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
automation_id = Column(
|
||||
String,
|
||||
ForeignKey('automations.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
event_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey('automation_events.id'),
|
||||
nullable=True,
|
||||
)
|
||||
conversation_id = Column(String, nullable=True)
|
||||
status = Column(String, nullable=False, server_default=text("'PENDING'"))
|
||||
claimed_by = Column(String, nullable=True)
|
||||
claimed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
heartbeat_at = Column(DateTime(timezone=True), nullable=True)
|
||||
retry_count = Column(Integer, nullable=False, server_default=text('0'))
|
||||
max_retries = Column(Integer, nullable=False, server_default=text('3'))
|
||||
next_retry_at = Column(DateTime(timezone=True), nullable=True)
|
||||
event_payload = Column(_JsonType, nullable=True)
|
||||
error_detail = Column(Text, nullable=True)
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=text('CURRENT_TIMESTAMP'),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
automation = relationship('Automation', back_populates='runs')
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
'ix_automation_runs_claimable',
|
||||
'status',
|
||||
'next_retry_at',
|
||||
postgresql_where=text("status = 'PENDING' AND (next_retry_at IS NULL OR next_retry_at <= now())"),
|
||||
),
|
||||
Index('ix_automation_runs_automation_id', 'automation_id'),
|
||||
Index(
|
||||
'ix_automation_runs_heartbeat',
|
||||
'heartbeat_at',
|
||||
postgresql_where=text("status = 'RUNNING'"),
|
||||
),
|
||||
)
|
||||
37
enterprise/storage/automation_event.py
Normal file
37
enterprise/storage/automation_event.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Column, DateTime, Index, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.types import JSON
|
||||
from storage.base import Base
|
||||
|
||||
_JsonType = JSON().with_variant(JSONB(), 'postgresql')
|
||||
|
||||
|
||||
class AutomationEvent(Base): # type: ignore
|
||||
"""Model for storing raw automation trigger events."""
|
||||
|
||||
__tablename__ = 'automation_events'
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
source_type = Column(String, nullable=False)
|
||||
payload = Column(_JsonType, nullable=False)
|
||||
metadata_ = Column('metadata', _JsonType, nullable=True)
|
||||
dedup_key = Column(String, nullable=False, unique=True)
|
||||
status = Column(String, nullable=False, server_default=text("'NEW'"))
|
||||
error_detail = Column(Text, nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=text('CURRENT_TIMESTAMP'),
|
||||
nullable=False,
|
||||
)
|
||||
processed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('dedup_key', name='uq_automation_events_dedup'),
|
||||
Index(
|
||||
'ix_automation_events_new',
|
||||
'created_at',
|
||||
postgresql_where=text("status = 'NEW'"),
|
||||
),
|
||||
)
|
||||
0
enterprise/tests/unit/services/__init__.py
Normal file
0
enterprise/tests/unit/services/__init__.py
Normal file
160
enterprise/tests/unit/services/test_automation_config.py
Normal file
160
enterprise/tests/unit/services/test_automation_config.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Tests for automation config extraction and validation."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from services.automation_config import extract_config, validate_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractConfig:
|
||||
def test_plain_dict(self):
|
||||
source = '''
|
||||
__config__ = {
|
||||
"name": "Daily Report",
|
||||
"triggers": {"cron": {"schedule": "0 9 * * 1"}},
|
||||
}
|
||||
'''
|
||||
cfg = extract_config(source)
|
||||
assert cfg['name'] == 'Daily Report'
|
||||
assert cfg['triggers']['cron']['schedule'] == '0 9 * * 1'
|
||||
|
||||
def test_annotated_assignment(self):
|
||||
source = '''
|
||||
__config__: dict = {
|
||||
"name": "Annotated",
|
||||
"triggers": {"cron": {"schedule": "*/5 * * * *"}},
|
||||
"description": "annotated config",
|
||||
}
|
||||
'''
|
||||
cfg = extract_config(source)
|
||||
assert cfg['name'] == 'Annotated'
|
||||
assert cfg['description'] == 'annotated config'
|
||||
|
||||
def test_no_config_raises(self):
|
||||
source = 'x = 1\ny = 2\n'
|
||||
with pytest.raises(ValueError, match='__config__ not found'):
|
||||
extract_config(source)
|
||||
|
||||
def test_non_literal_value_raises(self):
|
||||
source = '__config__ = some_function()\n'
|
||||
with pytest.raises(ValueError, match='literal expression'):
|
||||
extract_config(source)
|
||||
|
||||
def test_bad_syntax_raises(self):
|
||||
source = 'def foo(\n'
|
||||
with pytest.raises(ValueError, match='Failed to parse'):
|
||||
extract_config(source)
|
||||
|
||||
def test_config_not_dict_raises(self):
|
||||
source = '__config__ = [1, 2, 3]\n'
|
||||
with pytest.raises(ValueError, match='must be a dict'):
|
||||
extract_config(source)
|
||||
|
||||
def test_config_with_surrounding_code(self):
|
||||
source = '''
|
||||
import os
|
||||
|
||||
__config__ = {"name": "Mixed", "triggers": {"cron": {"schedule": "0 0 * * *"}}}
|
||||
|
||||
def main():
|
||||
pass
|
||||
'''
|
||||
cfg = extract_config(source)
|
||||
assert cfg['name'] == 'Mixed'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidateConfig:
|
||||
def test_valid_cron_config(self):
|
||||
cfg = {
|
||||
'name': 'My Automation',
|
||||
'triggers': {
|
||||
'cron': {'schedule': '0 9 * * 1', 'timezone': 'America/New_York'},
|
||||
},
|
||||
}
|
||||
model = validate_config(cfg)
|
||||
assert model.name == 'My Automation'
|
||||
assert model.triggers.cron is not None
|
||||
assert model.triggers.cron.schedule == '0 9 * * 1'
|
||||
assert model.triggers.cron.timezone == 'America/New_York'
|
||||
|
||||
def test_valid_cron_default_timezone(self):
|
||||
cfg = {
|
||||
'name': 'Simple',
|
||||
'triggers': {'cron': {'schedule': '*/5 * * * *'}},
|
||||
}
|
||||
model = validate_config(cfg)
|
||||
assert model.triggers.cron is not None
|
||||
assert model.triggers.cron.timezone == 'UTC'
|
||||
|
||||
def test_valid_with_description(self):
|
||||
cfg = {
|
||||
'name': 'Described',
|
||||
'triggers': {'cron': {'schedule': '0 0 * * *'}},
|
||||
'description': 'A helpful description',
|
||||
}
|
||||
model = validate_config(cfg)
|
||||
assert model.description == 'A helpful description'
|
||||
|
||||
def test_missing_name_raises(self):
|
||||
cfg = {
|
||||
'triggers': {'cron': {'schedule': '0 0 * * *'}},
|
||||
}
|
||||
with pytest.raises(ValidationError):
|
||||
validate_config(cfg)
|
||||
|
||||
def test_empty_name_raises(self):
|
||||
cfg = {
|
||||
'name': '',
|
||||
'triggers': {'cron': {'schedule': '0 0 * * *'}},
|
||||
}
|
||||
with pytest.raises(ValidationError):
|
||||
validate_config(cfg)
|
||||
|
||||
def test_name_too_long_raises(self):
|
||||
cfg = {
|
||||
'name': 'x' * 201,
|
||||
'triggers': {'cron': {'schedule': '0 0 * * *'}},
|
||||
}
|
||||
with pytest.raises(ValidationError):
|
||||
validate_config(cfg)
|
||||
|
||||
def test_missing_triggers_raises(self):
|
||||
cfg = {'name': 'No Triggers'}
|
||||
with pytest.raises(ValidationError):
|
||||
validate_config(cfg)
|
||||
|
||||
def test_empty_triggers_raises(self):
|
||||
cfg = {'name': 'Empty', 'triggers': {}}
|
||||
with pytest.raises(ValidationError, match='Exactly one trigger'):
|
||||
validate_config(cfg)
|
||||
|
||||
def test_invalid_cron_expression_raises(self):
|
||||
cfg = {
|
||||
'name': 'Bad Cron',
|
||||
'triggers': {'cron': {'schedule': 'not-a-cron'}},
|
||||
}
|
||||
with pytest.raises(ValidationError, match='Invalid cron expression'):
|
||||
validate_config(cfg)
|
||||
|
||||
def test_invalid_cron_too_few_fields(self):
|
||||
cfg = {
|
||||
'name': 'Short Cron',
|
||||
'triggers': {'cron': {'schedule': '* *'}},
|
||||
}
|
||||
with pytest.raises(ValidationError, match='Invalid cron expression'):
|
||||
validate_config(cfg)
|
||||
|
||||
def test_name_at_boundary_200(self):
|
||||
cfg = {
|
||||
'name': 'x' * 200,
|
||||
'triggers': {'cron': {'schedule': '0 0 * * *'}},
|
||||
}
|
||||
model = validate_config(cfg)
|
||||
assert len(model.name) == 200
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for automation event publisher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from services.automation_event_publisher import pg_notify_new_event, publish_automation_event
|
||||
from storage.automation_event import AutomationEvent
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
def _make_engine():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False})
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
class TestPublishAutomationEvent:
|
||||
def test_creates_event_with_correct_fields(self):
|
||||
engine = _make_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as session:
|
||||
event = publish_automation_event(
|
||||
session=session,
|
||||
source_type='cron',
|
||||
payload={'automation_id': 'abc'},
|
||||
dedup_key='cron-abc-2025',
|
||||
metadata={'extra': 'data'},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(AutomationEvent, event.id)
|
||||
assert fetched is not None
|
||||
assert fetched.source_type == 'cron'
|
||||
assert fetched.payload == {'automation_id': 'abc'}
|
||||
assert fetched.dedup_key == 'cron-abc-2025'
|
||||
assert fetched.metadata_ == {'extra': 'data'}
|
||||
assert fetched.status == 'NEW'
|
||||
|
||||
def test_creates_event_without_metadata(self):
|
||||
engine = _make_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as session:
|
||||
event = publish_automation_event(
|
||||
session=session,
|
||||
source_type='manual',
|
||||
payload={'test': True},
|
||||
dedup_key='manual-123',
|
||||
)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(AutomationEvent, event.id)
|
||||
assert fetched is not None
|
||||
assert fetched.metadata_ is None
|
||||
|
||||
|
||||
class TestPgNotifyNewEvent:
|
||||
def test_pg_notify_executes_sql(self):
|
||||
"""pg_notify uses PostgreSQL-specific function; verify it at least
|
||||
constructs the correct SQL statement. On SQLite this will fail at
|
||||
execution, so we just verify the function doesn't error before execute."""
|
||||
mock_session = MagicMock()
|
||||
pg_notify_new_event(mock_session, 42)
|
||||
mock_session.execute.assert_called_once()
|
||||
call_args = mock_session.execute.call_args
|
||||
sql_text = str(call_args[0][0])
|
||||
assert 'pg_notify' in sql_text
|
||||
104
enterprise/tests/unit/services/test_automation_file_generator.py
Normal file
104
enterprise/tests/unit/services/test_automation_file_generator.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Tests for automation file generator."""
|
||||
|
||||
import ast
|
||||
|
||||
from services.automation_config import extract_config, validate_config
|
||||
from services.automation_file_generator import generate_automation_file
|
||||
|
||||
|
||||
class TestGenerateAutomationFile:
|
||||
def test_generates_valid_python(self):
|
||||
source = generate_automation_file(
|
||||
name='Daily Report',
|
||||
schedule='0 9 * * 1',
|
||||
timezone='UTC',
|
||||
prompt='Generate the daily status report.',
|
||||
)
|
||||
# Must parse without error
|
||||
ast.parse(source)
|
||||
|
||||
def test_contains_config(self):
|
||||
source = generate_automation_file(
|
||||
name='Test Automation',
|
||||
schedule='*/5 * * * *',
|
||||
timezone='America/New_York',
|
||||
prompt='Do something useful.',
|
||||
)
|
||||
cfg = extract_config(source)
|
||||
assert cfg['name'] == 'Test Automation'
|
||||
assert cfg['triggers']['cron']['schedule'] == '*/5 * * * *'
|
||||
assert cfg['triggers']['cron']['timezone'] == 'America/New_York'
|
||||
|
||||
def test_round_trip(self):
|
||||
"""Generate → extract → validate must succeed."""
|
||||
source = generate_automation_file(
|
||||
name='Round Trip',
|
||||
schedule='30 14 * * 0',
|
||||
timezone='Europe/London',
|
||||
prompt='Weekly summary please.',
|
||||
)
|
||||
cfg = extract_config(source)
|
||||
model = validate_config(cfg)
|
||||
assert model.name == 'Round Trip'
|
||||
assert model.triggers.cron is not None
|
||||
assert model.triggers.cron.schedule == '30 14 * * 0'
|
||||
assert model.triggers.cron.timezone == 'Europe/London'
|
||||
|
||||
def test_contains_prompt(self):
|
||||
source = generate_automation_file(
|
||||
name='Prompt Test',
|
||||
schedule='0 0 * * *',
|
||||
timezone='UTC',
|
||||
prompt='Hello world!',
|
||||
)
|
||||
assert 'Hello world!' in source
|
||||
|
||||
def test_contains_docstring(self):
|
||||
source = generate_automation_file(
|
||||
name='Doc Test',
|
||||
schedule='0 0 * * *',
|
||||
timezone='UTC',
|
||||
prompt='test',
|
||||
)
|
||||
assert 'Doc Test' in source
|
||||
assert 'auto-generated automation' in source
|
||||
|
||||
def test_special_characters_in_prompt(self):
|
||||
source = generate_automation_file(
|
||||
name='Special Chars',
|
||||
schedule='0 0 * * *',
|
||||
timezone='UTC',
|
||||
prompt='Check the "status" of \\n stuff',
|
||||
)
|
||||
# Must still be valid Python
|
||||
ast.parse(source)
|
||||
cfg = extract_config(source)
|
||||
assert cfg['name'] == 'Special Chars'
|
||||
|
||||
def test_triple_quotes_in_name(self):
|
||||
"""Names containing triple quotes must not break the generated file."""
|
||||
source = generate_automation_file(
|
||||
name='Test """Demo""" Name',
|
||||
schedule='0 0 * * *',
|
||||
timezone='UTC',
|
||||
prompt='hello',
|
||||
)
|
||||
# Must still be valid Python
|
||||
ast.parse(source)
|
||||
cfg = extract_config(source)
|
||||
assert 'Demo' in cfg['name'] # name preserved in config
|
||||
|
||||
def test_triple_quotes_in_prompt(self):
|
||||
"""Prompts containing triple quotes must not break the generated file."""
|
||||
source = generate_automation_file(
|
||||
name='Triple Quote Test',
|
||||
schedule='0 0 * * *',
|
||||
timezone='UTC',
|
||||
prompt='Use """triple quotes""" and \'\'\'single triples\'\'\' safely',
|
||||
)
|
||||
# Must parse without error
|
||||
ast.parse(source)
|
||||
cfg = extract_config(source)
|
||||
assert cfg['name'] == 'Triple Quote Test'
|
||||
# The prompt must survive round-trip
|
||||
assert '"""triple quotes"""' in source or "triple quotes" in source
|
||||
184
enterprise/tests/unit/storage/test_automation_models.py
Normal file
184
enterprise/tests/unit/storage/test_automation_models.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Tests for Automation and AutomationRun SQLAlchemy models."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.automation import Automation, AutomationRun
|
||||
from storage.automation_event import AutomationEvent
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
def _make_engine():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False})
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
class TestAutomationModel:
|
||||
def test_create_automation_with_defaults(self):
|
||||
engine = _make_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as session:
|
||||
auto = Automation(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id='user-1',
|
||||
name='My Automation',
|
||||
config={'name': 'My Automation', 'triggers': {'cron': {'schedule': '0 9 * * 1'}}},
|
||||
trigger_type='cron',
|
||||
file_store_key='automations/user-1/abc.py',
|
||||
)
|
||||
session.add(auto)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(Automation, auto.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name == 'My Automation'
|
||||
assert fetched.user_id == 'user-1'
|
||||
assert fetched.trigger_type == 'cron'
|
||||
assert fetched.org_id is None
|
||||
assert fetched.last_triggered_at is None
|
||||
|
||||
def test_automation_with_org_id(self):
|
||||
engine = _make_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as session:
|
||||
auto = Automation(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id='user-1',
|
||||
org_id='org-123',
|
||||
name='Org Automation',
|
||||
config={'name': 'Org Automation'},
|
||||
trigger_type='cron',
|
||||
file_store_key='automations/user-1/xyz.py',
|
||||
)
|
||||
session.add(auto)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(Automation, auto.id)
|
||||
assert fetched is not None
|
||||
assert fetched.org_id == 'org-123'
|
||||
|
||||
|
||||
class TestAutomationRunModel:
|
||||
def test_create_run_with_defaults(self):
|
||||
engine = _make_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as session:
|
||||
auto = Automation(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id='user-1',
|
||||
name='Test',
|
||||
config={},
|
||||
trigger_type='cron',
|
||||
file_store_key='test.py',
|
||||
)
|
||||
session.add(auto)
|
||||
session.flush()
|
||||
|
||||
run = AutomationRun(
|
||||
id=uuid.uuid4().hex,
|
||||
automation_id=auto.id,
|
||||
)
|
||||
session.add(run)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(AutomationRun, run.id)
|
||||
assert fetched is not None
|
||||
assert fetched.automation_id == auto.id
|
||||
assert fetched.conversation_id is None
|
||||
assert fetched.event_id is None
|
||||
assert fetched.claimed_by is None
|
||||
assert fetched.error_detail is None
|
||||
|
||||
def test_run_relationship(self):
|
||||
engine = _make_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as session:
|
||||
auto = Automation(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id='user-1',
|
||||
name='Test',
|
||||
config={},
|
||||
trigger_type='cron',
|
||||
file_store_key='test.py',
|
||||
)
|
||||
session.add(auto)
|
||||
session.flush()
|
||||
|
||||
run = AutomationRun(
|
||||
id=uuid.uuid4().hex,
|
||||
automation_id=auto.id,
|
||||
)
|
||||
session.add(run)
|
||||
session.commit()
|
||||
|
||||
session.refresh(auto)
|
||||
assert len(auto.runs) == 1
|
||||
assert auto.runs[0].id == run.id
|
||||
assert run.automation.id == auto.id
|
||||
|
||||
|
||||
class TestAutomationEventModel:
|
||||
def test_create_event(self):
|
||||
engine = _make_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as session:
|
||||
event = AutomationEvent(
|
||||
source_type='cron',
|
||||
payload={'tick': True},
|
||||
dedup_key='cron-2025-01-01T00:00:00',
|
||||
)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(AutomationEvent, event.id)
|
||||
assert fetched is not None
|
||||
assert fetched.source_type == 'cron'
|
||||
assert fetched.payload == {'tick': True}
|
||||
assert fetched.dedup_key == 'cron-2025-01-01T00:00:00'
|
||||
assert fetched.error_detail is None
|
||||
|
||||
def test_event_with_metadata(self):
|
||||
engine = _make_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as session:
|
||||
event = AutomationEvent(
|
||||
source_type='github',
|
||||
payload={'action': 'opened'},
|
||||
dedup_key='gh-12345',
|
||||
metadata_={'installation_id': 99},
|
||||
)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(AutomationEvent, event.id)
|
||||
assert fetched is not None
|
||||
assert fetched.metadata_ == {'installation_id': 99}
|
||||
|
||||
def test_dedup_key_unique(self):
|
||||
engine = _make_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
import sqlalchemy
|
||||
|
||||
with Session() as session:
|
||||
e1 = AutomationEvent(
|
||||
source_type='cron',
|
||||
payload={},
|
||||
dedup_key='dup-key',
|
||||
)
|
||||
session.add(e1)
|
||||
session.commit()
|
||||
|
||||
with Session() as session:
|
||||
e2 = AutomationEvent(
|
||||
source_type='cron',
|
||||
payload={},
|
||||
dedup_key='dup-key',
|
||||
)
|
||||
session.add(e2)
|
||||
try:
|
||||
session.commit()
|
||||
assert False, 'Expected IntegrityError'
|
||||
except sqlalchemy.exc.IntegrityError:
|
||||
session.rollback()
|
||||
@@ -16,6 +16,7 @@ class ConversationTrigger(Enum):
|
||||
JIRA_DC = 'jira_dc'
|
||||
LINEAR = 'linear'
|
||||
BITBUCKET = 'bitbucket'
|
||||
AUTOMATION = 'automation'
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
Reference in New Issue
Block a user