Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
222bac0
feat(automations): implement scheduled automation cycles and run deta…
Kazet111 Apr 19, 2026
332ca83
fix(ci): satisfy ruff format and ty checks for automations module
Kazet111 Apr 19, 2026
2d9d86d
test(automations): improve frontend coverage for run details and list…
Kazet111 Apr 19, 2026
4531bf1
fix: stabilize automation run cycle snapshots and dispatch scheduling
Kazet111 Apr 19, 2026
57ba9b6
fix: prevent same-day automation catch-up after late schedule changes
Kazet111 Apr 20, 2026
c3b3c60
fix(db): linearize request_logs migration chain after main merge
Kazet111 Apr 21, 2026
5d1a278
fix(db): add alembic merge revision for request_logs heads
Kazet111 Apr 21, 2026
a5d2d39
fix(frontend,db): restore AccountMultiSelect trigger/aria props and m…
Kazet111 Apr 21, 2026
f591133
test(automations): relax account selector assertion in integration fl…
Kazet111 Apr 22, 2026
674e41d
fix(automations): normalize legacy cycle keys and honor status-filter…
Kazet111 Apr 22, 2026
6c8de05
fix(automations): use cycle-level terminal state for grouped runs and…
Kazet111 Apr 22, 2026
6a97150
fix(automations): preserve cycle outcomes for ineligible accounts
Kazet111 May 20, 2026
6bd893f
fix(automations): harden delayed cycle dispatch handling
Kazet111 May 20, 2026
dd3514e
fix(automations): order scheduled cycles by first dispatch
Komzpa May 22, 2026
10b6c6a
fix(db): merge automation head after request log index
Komzpa May 22, 2026
32b5ef8
fix(automations): avoid reclaiming active manual runs
Komzpa May 22, 2026
9637a54
fix(automations): execute run-now manual claims immediately
Komzpa May 22, 2026
377ee20
test(automations): align manual cycle expectations with direct run-now
Komzpa May 22, 2026
b2fd63a
fix(automations): reclaim timed-out manual run claims
Komzpa May 22, 2026
2cb959b
fix(automations): timestamp scheduled groups by cycle start
Komzpa May 23, 2026
39cd211
fix(automations): scope run-now manual execution to cycle
Komzpa May 23, 2026
5cbf810
fix(automations): recover historical scheduled cycle keys
Komzpa May 23, 2026
e91cb57
fix(db): merge automations and reauth migration heads
Komzpa Jun 4, 2026
2da8339
fix(automations): execute due manual run-now slots
Komzpa Jun 4, 2026
3335403
fix(automations): skip reauth-required accounts
Komzpa Jun 4, 2026
799c4d5
fix(automations): fail over forced runs
Komzpa Jun 4, 2026
a0e11eb
fix(migrations): merge automations rebase heads
Komzpa Jun 11, 2026
a21775f
fix(automations): order manual cycle slots deterministically
Komzpa Jun 11, 2026
59bee61
fix(automations): treat successful failover as complete
Komzpa Jun 11, 2026
f0a15ab
fix(automations): mark retry success partial
Komzpa Jun 11, 2026
14c5f9b
style(automations): format retry status expression
Komzpa Jun 11, 2026
aac9200
fix(automations): reclaim stale scheduled runs and route pings
Komzpa Jun 11, 2026
6dabd27
style(automations): format scheduled reclaim tests
Komzpa Jun 11, 2026
0834578
fix(automations): preserve completed cycle members
Komzpa Jun 11, 2026
ee4756d
fix(automations): pin cycle slot execution accounts
Komzpa Jun 11, 2026
1ef7462
fix(automations): surface stale scheduled cycles
Komzpa Jun 11, 2026
4550ec2
fix(automations): preserve empty-cycle run errors
Komzpa Jun 11, 2026
5b7c174
fix(automations): use account duplicate marker
Komzpa Jun 15, 2026
b60a08c
fix(automations): finish review fixes
Komzpa Jun 15, 2026
d9368b1
fix(automations): ignore hidden placeholders in cycle status
Komzpa Jun 15, 2026
64464f4
fix(db): merge automations and dashboard guest heads
Komzpa Jun 15, 2026
17b029a
fix(automations): filter manual cycle status by visible accounts
Komzpa Jun 15, 2026
2027987
fix(automations): honor failover for cycle runs
Komzpa Jun 16, 2026
06db0ec
fix(automations): enforce write access for mutation routes
Komzpa Jun 16, 2026
006f1f0
fix(automations): align due-cycle checks with slot key
Komzpa Jun 16, 2026
2587246
style(automations): format due cycle query changes
Komzpa Jun 16, 2026
62c3c93
fix(automations): preserve scheduled cycle slots
Komzpa Jun 16, 2026
27af6c5
fix(automations): preserve scheduled cycle claims
Komzpa Jun 16, 2026
6920ad0
fix(automations): fence scheduled cycle retries
Komzpa Jun 16, 2026
b18d86c
fix(automations): retry within cycle snapshot accounts
Komzpa Jun 16, 2026
e0223b8
fix(automations): recover scheduled slots from cycle keys
Komzpa Jun 16, 2026
8653cf8
Merge remote-tracking branch 'origin/main' into repair/pr-438-merge-2…
Komzpa Jun 30, 2026
4c01ded
fix(automations): preserve cycle dispatch snapshots
Komzpa Jun 30, 2026
af211ba
fix(automations): preserve run execution snapshots
Komzpa Jun 30, 2026
786b2ee
fix(automations): summarize pending cycle accounts from snapshot
Komzpa Jun 30, 2026
f4bdde8
Merge remote-tracking branch 'origin/main' into repair/pr-438-merge-2…
Komzpa Jun 30, 2026
05d2b7f
fix(automations): merge current migration heads
Komzpa Jun 30, 2026
c8a7a6a
Merge remote-tracking branch 'origin/main' into HEAD
Komzpa Jul 7, 2026
3efb906
fix(db): merge automations migration head
Komzpa Jul 7, 2026
7d3a748
fix(automations): reject non-routable models
Komzpa Jul 7, 2026
65f0b81
fix(automations): align cycle status filters with eligibility
Komzpa Jul 7, 2026
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
2 changes: 2 additions & 0 deletions app/core/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ class Settings(BaseSettings):
sticky_session_cleanup_interval_seconds: int = Field(default=300, gt=0)
quota_planner_scheduler_enabled: bool = True
quota_planner_tick_seconds: int = Field(default=300, gt=0)
automations_scheduler_enabled: bool = True
automations_scheduler_interval_seconds: int = Field(default=30, gt=0)
encryption_key_file: Path = DEFAULT_ENCRYPTION_KEY_FILE
database_migrations_fail_fast: bool = True
log_proxy_request_shape: bool = False
Expand Down
189 changes: 189 additions & 0 deletions app/db/alembic/versions/20260415_160000_add_automations_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""add automations tables

Revision ID: 20260415_160000_add_automations_tables
Revises: 20260415_160000_add_request_logs_response_lookup_index
Create Date: 2026-04-15
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op
from sqlalchemy.engine import Connection

revision = "20260415_160000_add_automations_tables"
down_revision = "20260415_160000_add_request_logs_response_lookup_index"
branch_labels = None
depends_on = None


def _table_exists(connection: Connection, table_name: str) -> bool:
inspector = sa.inspect(connection)
return inspector.has_table(table_name)


def _index_names(connection: Connection, table_name: str) -> set[str]:
inspector = sa.inspect(connection)
if not inspector.has_table(table_name):
return set()
return {str(index["name"]) for index in inspector.get_indexes(table_name) if index.get("name")}


def _column_names(connection: Connection, table_name: str) -> set[str]:
inspector = sa.inspect(connection)
if not inspector.has_table(table_name):
return set()
return {str(column["name"]) for column in inspector.get_columns(table_name) if column.get("name")}


def upgrade() -> None:
bind = op.get_bind()

if not _table_exists(bind, "automation_jobs"):
op.create_table(
"automation_jobs",
sa.Column("id", sa.String(), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("schedule_type", sa.String(length=32), nullable=False, server_default=sa.text("'daily'")),
sa.Column("schedule_time", sa.String(length=5), nullable=False),
sa.Column("schedule_timezone", sa.String(length=64), nullable=False),
sa.Column(
"schedule_days",
sa.String(length=64),
nullable=False,
server_default=sa.text("'mon,tue,wed,thu,fri,sat,sun'"),
),
sa.Column("schedule_threshold_minutes", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("include_paused_accounts", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("model", sa.String(), nullable=False),
sa.Column("reasoning_effort", sa.String(length=16), nullable=True),
sa.Column("prompt", sa.Text(), nullable=False, server_default=sa.text("'ping'")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
else:
job_columns = _column_names(bind, "automation_jobs")
if "schedule_days" not in job_columns:
op.add_column(
"automation_jobs",
sa.Column(
"schedule_days",
sa.String(length=64),
nullable=False,
server_default=sa.text("'mon,tue,wed,thu,fri,sat,sun'"),
),
)
if "reasoning_effort" not in job_columns:
op.add_column(
"automation_jobs",
sa.Column("reasoning_effort", sa.String(length=16), nullable=True),
)
if "schedule_threshold_minutes" not in job_columns:
op.add_column(
"automation_jobs",
sa.Column("schedule_threshold_minutes", sa.Integer(), nullable=False, server_default=sa.text("0")),
)
if "include_paused_accounts" not in job_columns:
op.add_column(
"automation_jobs",
sa.Column("include_paused_accounts", sa.Boolean(), nullable=False, server_default=sa.false()),
)

if not _table_exists(bind, "automation_job_accounts"):
op.create_table(
"automation_job_accounts",
sa.Column("job_id", sa.String(), nullable=False),
sa.Column("account_id", sa.String(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["job_id"], ["automation_jobs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("job_id", "account_id"),
sa.UniqueConstraint("job_id", "position", name="uq_automation_job_accounts_position"),
)

if not _table_exists(bind, "automation_runs"):
op.create_table(
"automation_runs",
sa.Column("id", sa.String(), nullable=False),
sa.Column("job_id", sa.String(), nullable=False),
sa.Column("trigger", sa.String(length=16), nullable=False),
sa.Column("slot_key", sa.String(length=128), nullable=False),
sa.Column("scheduled_for", sa.DateTime(), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False, server_default=sa.text("'running'")),
sa.Column("account_id", sa.String(), nullable=True),
sa.Column("error_code", sa.String(length=100), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["job_id"], ["automation_jobs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slot_key", name="uq_automation_runs_slot_key"),
)

jobs_indexes = _index_names(bind, "automation_jobs")
if "idx_automation_jobs_enabled" not in jobs_indexes:
op.create_index("idx_automation_jobs_enabled", "automation_jobs", ["enabled"], unique=False)

job_accounts_indexes = _index_names(bind, "automation_job_accounts")
if "idx_automation_job_accounts_account_id" not in job_accounts_indexes:
op.create_index(
"idx_automation_job_accounts_account_id",
"automation_job_accounts",
["account_id"],
unique=False,
)

runs_indexes = _index_names(bind, "automation_runs")
if "idx_automation_runs_job_id_started_at" not in runs_indexes:
op.create_index(
"idx_automation_runs_job_id_started_at",
"automation_runs",
["job_id", "started_at"],
unique=False,
)
if "idx_automation_runs_status_started_at" not in runs_indexes:
op.create_index(
"idx_automation_runs_status_started_at",
"automation_runs",
["status", "started_at"],
unique=False,
)
if "idx_automation_runs_scheduled_for" not in runs_indexes:
op.create_index(
"idx_automation_runs_scheduled_for",
"automation_runs",
["scheduled_for"],
unique=False,
)


def downgrade() -> None:
bind = op.get_bind()

if _table_exists(bind, "automation_runs"):
runs_indexes = _index_names(bind, "automation_runs")
if "idx_automation_runs_scheduled_for" in runs_indexes:
op.drop_index("idx_automation_runs_scheduled_for", table_name="automation_runs")
if "idx_automation_runs_status_started_at" in runs_indexes:
op.drop_index("idx_automation_runs_status_started_at", table_name="automation_runs")
if "idx_automation_runs_job_id_started_at" in runs_indexes:
op.drop_index("idx_automation_runs_job_id_started_at", table_name="automation_runs")
op.drop_table("automation_runs")

if _table_exists(bind, "automation_job_accounts"):
job_accounts_indexes = _index_names(bind, "automation_job_accounts")
if "idx_automation_job_accounts_account_id" in job_accounts_indexes:
op.drop_index("idx_automation_job_accounts_account_id", table_name="automation_job_accounts")
op.drop_table("automation_job_accounts")

if _table_exists(bind, "automation_jobs"):
jobs_indexes = _index_names(bind, "automation_jobs")
if "idx_automation_jobs_enabled" in jobs_indexes:
op.drop_index("idx_automation_jobs_enabled", table_name="automation_jobs")
op.drop_table("automation_jobs")
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""add cycle key to automation runs

Revision ID: 20260418_190000_add_automation_runs_cycle_key
Revises: 20260415_160000_add_automations_tables
Create Date: 2026-04-18
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op
from sqlalchemy.engine import Connection

revision = "20260418_190000_add_automation_runs_cycle_key"
down_revision = "20260415_160000_add_automations_tables"
branch_labels = None
depends_on = None


def _table_exists(connection: Connection, table_name: str) -> bool:
inspector = sa.inspect(connection)
return inspector.has_table(table_name)


def _index_names(connection: Connection, table_name: str) -> set[str]:
inspector = sa.inspect(connection)
if not inspector.has_table(table_name):
return set()
return {str(index["name"]) for index in inspector.get_indexes(table_name) if index.get("name")}


def _column_names(connection: Connection, table_name: str) -> set[str]:
inspector = sa.inspect(connection)
if not inspector.has_table(table_name):
return set()
return {str(column["name"]) for column in inspector.get_columns(table_name) if column.get("name")}


def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "automation_runs"):
return

run_columns = _column_names(bind, "automation_runs")
if "cycle_key" not in run_columns:
op.add_column(
"automation_runs",
sa.Column("cycle_key", sa.String(length=160), nullable=True),
)
op.execute("UPDATE automation_runs SET cycle_key = slot_key WHERE cycle_key IS NULL OR cycle_key = ''")
if bind.dialect.name == "sqlite":
with op.batch_alter_table("automation_runs", recreate="always") as batch_op:
batch_op.alter_column(
"cycle_key",
existing_type=sa.String(length=160),
nullable=False,
)
else:
op.alter_column(
"automation_runs",
"cycle_key",
existing_type=sa.String(length=160),
nullable=False,
)

run_indexes = _index_names(bind, "automation_runs")
if "idx_automation_runs_cycle_key_started_at" not in run_indexes:
op.create_index(
"idx_automation_runs_cycle_key_started_at",
"automation_runs",
["cycle_key", "started_at"],
unique=False,
)


def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "automation_runs"):
return

run_indexes = _index_names(bind, "automation_runs")
if "idx_automation_runs_cycle_key_started_at" in run_indexes:
op.drop_index("idx_automation_runs_cycle_key_started_at", table_name="automation_runs")

run_columns = _column_names(bind, "automation_runs")
if "cycle_key" in run_columns:
op.drop_column("automation_runs", "cycle_key")
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""add automation run cycle metadata columns

Revision ID: 20260419_000000_add_automation_run_cycle_metadata
Revises: 20260418_190000_add_automation_runs_cycle_key
Create Date: 2026-04-19
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op
from sqlalchemy.engine import Connection

revision = "20260419_000000_add_automation_run_cycle_metadata"
down_revision = "20260418_190000_add_automation_runs_cycle_key"
branch_labels = None
depends_on = None


def _table_exists(connection: Connection, table_name: str) -> bool:
inspector = sa.inspect(connection)
return inspector.has_table(table_name)


def _column_names(connection: Connection, table_name: str) -> set[str]:
inspector = sa.inspect(connection)
if not inspector.has_table(table_name):
return set()
return {str(column["name"]) for column in inspector.get_columns(table_name) if column.get("name")}


def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "automation_runs"):
return

run_columns = _column_names(bind, "automation_runs")
if "cycle_expected_accounts" not in run_columns:
op.add_column("automation_runs", sa.Column("cycle_expected_accounts", sa.Integer(), nullable=True))
if "cycle_window_end" not in run_columns:
op.add_column("automation_runs", sa.Column("cycle_window_end", sa.DateTime(), nullable=True))

op.execute("UPDATE automation_runs SET cycle_expected_accounts = 0 WHERE cycle_expected_accounts IS NULL")
op.execute("UPDATE automation_runs SET cycle_window_end = scheduled_for WHERE cycle_window_end IS NULL")


def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "automation_runs"):
return

run_columns = _column_names(bind, "automation_runs")
if "cycle_window_end" in run_columns:
op.drop_column("automation_runs", "cycle_window_end")
if "cycle_expected_accounts" in run_columns:
op.drop_column("automation_runs", "cycle_expected_accounts")
Loading
Loading