Skip to content

Commit c44f454

Browse files
committed
feat(seer): Add automation config scope fields and legacy-only uniqueness
Add the scope, settings, and version columns, the SeerWorkflowConfigProject table, and the agentic_triage_v2 strategy. Uniqueness on (organization, strategy) now covers only legacy strategies so an organization can hold many agentic_triage_v2 configs, and the new partial index is created before the old constraint is dropped. get_or_create_for_strategy accepts only legacy strategies. Refs AIML-3722
1 parent 0586ea0 commit c44f454

8 files changed

Lines changed: 302 additions & 9 deletions

File tree

‎migrations_lockfile.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ releases: 0004_cleanup_failed_safe_deletes
3333

3434
replays: 0001_squashed_0007_organizationmember_replay_access
3535

36-
seer: 0037_rename_agentic_triage_result
36+
seer: 0038_add_workflow_config_scope
3737

3838
sentry: 1217_grouprelease_add_new_release_id
3939

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Generated by Django 5.2.17 on 2026-10-16 21:59
2+
3+
import django.contrib.postgres.fields
4+
import django.db.models.deletion
5+
import sentry.db.models.fields.bounded
6+
import sentry.db.models.fields.foreignkey
7+
from django.db import migrations, models
8+
9+
from sentry.new_migrations.migrations import CheckedMigration
10+
11+
12+
class Migration(CheckedMigration):
13+
# This flag is used to mark that a migration shouldn't be automatically run in production.
14+
# This should only be used for operations where it's safe to run the migration after your
15+
# code has deployed. So this should not be used for most operations that alter the schema
16+
# of a table.
17+
# Here are some things that make sense to mark as post deployment:
18+
# - Large data migrations. Typically we want these to be run manually so that they can be
19+
# monitored and not block the deploy for a long period of time while they run.
20+
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
21+
# run this outside deployments so that we don't block them. Note that while adding an index
22+
# is a schema change, it's completely safe to run the operation after the code has deployed.
23+
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
24+
25+
is_post_deployment = False
26+
27+
dependencies = [
28+
("seer", "0037_rename_agentic_triage_result"),
29+
("sentry", "1217_grouprelease_add_new_release_id"),
30+
]
31+
32+
operations = [
33+
migrations.CreateModel(
34+
name="SeerWorkflowConfigProject",
35+
fields=[
36+
(
37+
"id",
38+
sentry.db.models.fields.bounded.BoundedBigAutoField(
39+
primary_key=True, serialize=False
40+
),
41+
),
42+
("date_updated", models.DateTimeField(auto_now=True)),
43+
("date_added", models.DateTimeField(auto_now_add=True)),
44+
(
45+
"config",
46+
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
47+
on_delete=django.db.models.deletion.CASCADE, to="seer.seerworkflowconfig"
48+
),
49+
),
50+
(
51+
"project",
52+
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
53+
on_delete=django.db.models.deletion.CASCADE, to="sentry.project"
54+
),
55+
),
56+
],
57+
options={
58+
"db_table": "seer_workflowconfigproject",
59+
},
60+
),
61+
migrations.AddField(
62+
model_name="seerworkflowconfig",
63+
name="environments",
64+
field=django.contrib.postgres.fields.ArrayField(
65+
base_field=models.TextField(), db_default=[], default=list, size=None
66+
),
67+
),
68+
migrations.AddField(
69+
model_name="seerworkflowconfig",
70+
name="execution_policy",
71+
field=models.JSONField(db_default={}, default=dict),
72+
),
73+
migrations.AddField(
74+
model_name="seerworkflowconfig",
75+
name="is_all_projects",
76+
field=models.BooleanField(db_default=False, default=False),
77+
),
78+
migrations.AddField(
79+
model_name="seerworkflowconfig",
80+
name="output_policy",
81+
field=models.JSONField(db_default={}, default=dict),
82+
),
83+
migrations.AddField(
84+
model_name="seerworkflowconfig",
85+
name="strategy_options",
86+
field=models.JSONField(db_default={}, default=dict),
87+
),
88+
migrations.AddField(
89+
model_name="seerworkflowconfig",
90+
name="filters",
91+
field=models.JSONField(db_default={}, default=dict),
92+
),
93+
migrations.AddField(
94+
model_name="seerworkflowconfig",
95+
name="service_account_id",
96+
field=sentry.db.models.fields.bounded.BoundedBigIntegerField(null=True),
97+
),
98+
migrations.AddField(
99+
model_name="seerworkflowconfig",
100+
name="strategy_version",
101+
field=sentry.db.models.fields.bounded.BoundedPositiveIntegerField(
102+
db_default=1, default=1
103+
),
104+
),
105+
migrations.AddField(
106+
model_name="seerworkflowconfig",
107+
name="version",
108+
field=sentry.db.models.fields.bounded.BoundedPositiveIntegerField(
109+
db_default=1, default=1
110+
),
111+
),
112+
migrations.AddConstraint(
113+
model_name="seerworkflowconfig",
114+
constraint=models.UniqueConstraint(
115+
condition=models.Q(("strategy__in", ["agentic_triage", "duplicate_monitors"])),
116+
fields=("organization", "strategy"),
117+
name="seer_legacy_org_strategy_unique",
118+
),
119+
),
120+
migrations.RemoveConstraint(
121+
model_name="seerworkflowconfig",
122+
name="seer_workflowconfig_org_strategy_uniq",
123+
),
124+
migrations.AddConstraint(
125+
model_name="seerworkflowconfigproject",
126+
constraint=models.UniqueConstraint(
127+
fields=("config", "project"), name="seer_workflowconfigproject_unique"
128+
),
129+
),
130+
]

‎src/sentry/seer/models/workflow.py‎

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,39 @@
11
from __future__ import annotations
22

3+
from typing import Literal, get_args
4+
5+
from django.contrib.postgres.fields.array import ArrayField
36
from django.db import models
47

58
from sentry.backup.scopes import RelocationScope
6-
from sentry.db.models import FlexibleForeignKey, cell_silo_model, sane_repr
9+
from sentry.db.models import (
10+
BoundedBigIntegerField,
11+
BoundedPositiveIntegerField,
12+
FlexibleForeignKey,
13+
cell_silo_model,
14+
sane_repr,
15+
)
716
from sentry.db.models.base import DefaultFieldsModel
817

918

1019
class SeerWorkflowStrategy(models.TextChoices):
1120
AGENTIC_TRIAGE = "agentic_triage"
1221
DUPLICATE_MONITORS = "duplicate_monitors"
22+
AGENTIC_TRIAGE_V2 = "agentic_triage_v2"
23+
24+
25+
# Strategies with one config per organization, found or created by get_or_create_for_strategy.
26+
SeerLegacyWorkflowStrategy = Literal[
27+
SeerWorkflowStrategy.AGENTIC_TRIAGE, SeerWorkflowStrategy.DUPLICATE_MONITORS
28+
]
29+
# Strategies with any number of configs per organization. Each config is looked up by its id.
30+
SeerConfigurableWorkflowStrategy = Literal[SeerWorkflowStrategy.AGENTIC_TRIAGE_V2]
31+
SEER_LEGACY_WORKFLOW_STRATEGIES: frozenset[SeerWorkflowStrategy] = frozenset(
32+
get_args(SeerLegacyWorkflowStrategy)
33+
)
34+
SEER_CONFIGURABLE_WORKFLOW_STRATEGIES: frozenset[SeerWorkflowStrategy] = frozenset(
35+
get_args(SeerConfigurableWorkflowStrategy)
36+
)
1337

1438

1539
class SeerWorkflowSchedule(models.TextChoices):
@@ -18,6 +42,12 @@ class SeerWorkflowSchedule(models.TextChoices):
1842

1943
@cell_silo_model
2044
class SeerWorkflowConfig(DefaultFieldsModel):
45+
"""An automation's saved configuration.
46+
47+
Repositories are not configured here. Execution uses the resolved projects' existing Seer
48+
project-repository associations.
49+
"""
50+
2151
__relocation_scope__ = RelocationScope.Excluded
2252

2353
organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE)
@@ -26,30 +56,78 @@ class SeerWorkflowConfig(DefaultFieldsModel):
2656
schedule = models.CharField(
2757
max_length=256, choices=SeerWorkflowSchedule.choices, default=SeerWorkflowSchedule.DAILY
2858
)
59+
# Legacy free-form field.
2960
extras = models.JSONField(db_default={}, default=dict)
61+
strategy_version = BoundedPositiveIntegerField(default=1, db_default=1)
62+
version = BoundedPositiveIntegerField(default=1, db_default=1)
63+
# Each strategy defines and validates the shape of the JSON fields below.
64+
# How the automation does its work, such as {"intelligence_level": "high"}.
65+
strategy_options = models.JSONField(default=dict, db_default={})
66+
# Which work it picks beyond projects and environments, such as {"min_event_count": 10}.
67+
# These are work filters, not access limits. The service account controls access.
68+
filters = models.JSONField(default=dict, db_default={})
69+
# Where results go and how they are delivered: destinations, notifications and review.
70+
output_policy = models.JSONField(default=dict, db_default={})
71+
# How a run executes: batch size, concurrency, overlap with a previous run and limits.
72+
execution_policy = models.JSONField(default=dict, db_default={})
73+
# False with no SeerWorkflowConfigProject rows means no selected projects remain, so a config
74+
# saved without a project selection never widens to all projects.
75+
is_all_projects = models.BooleanField(default=False, db_default=False)
76+
# An empty list means all environments.
77+
environments = ArrayField(models.TextField(), default=list, db_default=[])
78+
# Id from the service-account management API. Becomes a HybridCloudForeignKey once the
79+
# control-silo model lands.
80+
service_account_id = BoundedBigIntegerField(null=True)
3081

3182
class Meta:
3283
app_label = "seer"
3384
db_table = "seer_workflowconfig"
3485
constraints = [
3586
models.UniqueConstraint(
36-
fields=["organization", "strategy"], name="seer_workflowconfig_org_strategy_uniq"
87+
fields=["organization", "strategy"],
88+
condition=models.Q(
89+
strategy__in=sorted(s.value for s in SEER_LEGACY_WORKFLOW_STRATEGIES)
90+
),
91+
name="seer_legacy_org_strategy_unique",
3792
),
3893
]
3994

4095
__repr__ = sane_repr("organization_id", "strategy", "enabled")
4196

4297
@classmethod
4398
def get_or_create_for_strategy(
44-
cls, organization_id: int, strategy: SeerWorkflowStrategy
99+
cls, organization_id: int, strategy: SeerLegacyWorkflowStrategy
45100
) -> SeerWorkflowConfig:
101+
if strategy not in SEER_LEGACY_WORKFLOW_STRATEGIES:
102+
raise ValueError(f"{strategy} configs are addressed by id, not by strategy")
46103
config, _ = cls.objects.get_or_create(
47104
organization_id=organization_id,
48105
strategy=strategy.value,
49106
)
50107
return config
51108

52109

110+
@cell_silo_model
111+
class SeerWorkflowConfigProject(DefaultFieldsModel):
112+
"""One selected project of a config whose `is_all_projects` is False."""
113+
114+
__relocation_scope__ = RelocationScope.Excluded
115+
116+
config = FlexibleForeignKey("seer.SeerWorkflowConfig", on_delete=models.CASCADE)
117+
project = FlexibleForeignKey("sentry.Project", on_delete=models.CASCADE)
118+
119+
class Meta:
120+
app_label = "seer"
121+
db_table = "seer_workflowconfigproject"
122+
constraints = [
123+
models.UniqueConstraint(
124+
fields=["config", "project"], name="seer_workflowconfigproject_unique"
125+
),
126+
]
127+
128+
__repr__ = sane_repr("config_id", "project_id")
129+
130+
53131
@cell_silo_model
54132
class SeerWorkflowRun(DefaultFieldsModel):
55133
"""Records each workflow invocation for an organization.

‎src/sentry/seer/workflows/runs.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
from sentry.seer.agent.types import FeatureRunStatus
1414
from sentry.seer.models.run import SeerAgentRun, SeerRun, SeerRunMirrorStatus
1515
from sentry.seer.models.workflow import (
16+
SeerLegacyWorkflowStrategy,
1617
SeerWorkflowConfig,
1718
SeerWorkflowRun,
1819
SeerWorkflowRunExecution,
19-
SeerWorkflowStrategy,
2020
)
2121
from sentry.seer.workflows.schemas import WorkflowResult, WorkflowResultError, WorkflowRunExtras
2222

@@ -27,7 +27,7 @@
2727
def create_workflow_run(
2828
client: SeerAgentClient,
2929
*,
30-
strategy: SeerWorkflowStrategy,
30+
strategy: SeerLegacyWorkflowStrategy,
3131
feature_id: str,
3232
payload: dict[str, Any],
3333
title: str,
@@ -46,7 +46,7 @@ def create_workflow_run(
4646
return run.workflow_execution.run
4747

4848

49-
def _create_workflow_run(seer_run: SeerRun, *, strategy: SeerWorkflowStrategy) -> None:
49+
def _create_workflow_run(seer_run: SeerRun, *, strategy: SeerLegacyWorkflowStrategy) -> None:
5050
config = SeerWorkflowConfig.get_or_create_for_strategy(seer_run.organization_id, strategy)
5151
run = SeerWorkflowRun.objects.create(
5252
organization_id=seer_run.organization_id, workflow_config=config

‎src/sentry/testutils/factories.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@
202202
SeerRunPullRequest,
203203
SeerRunType,
204204
)
205-
from sentry.seer.models.workflow import SeerWorkflowRun, SeerWorkflowRunExecution
205+
from sentry.seer.models.workflow import (
206+
SeerWorkflowConfig,
207+
SeerWorkflowRun,
208+
SeerWorkflowRunExecution,
209+
)
206210
from sentry.sentry_apps.installations import (
207211
SentryAppInstallationCreator,
208212
SentryAppInstallationTokenCreator,
@@ -3417,6 +3421,11 @@ def create_seer_agent_write_grant(organization, user, session_id: str = "s1", **
34173421
**kwargs,
34183422
)
34193423

3424+
@staticmethod
3425+
@assume_test_silo_mode(SiloMode.CELL)
3426+
def create_seer_workflow_config(organization, **kwargs) -> SeerWorkflowConfig:
3427+
return SeerWorkflowConfig.objects.create(organization=organization, **kwargs)
3428+
34203429
@staticmethod
34213430
@assume_test_silo_mode(SiloMode.CELL)
34223431
def create_seer_workflow_run(organization, **kwargs) -> SeerWorkflowRun:

‎tests/sentry/seer/endpoints/test_organization_seer_workflows.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from sentry.seer.models.run import SeerAgentRun, SeerRunPullRequest
1414
from sentry.seer.models.workflow import (
15+
SeerLegacyWorkflowStrategy,
1516
SeerWorkflowConfig,
1617
SeerWorkflowRun,
1718
SeerWorkflowRunExecution,
@@ -393,6 +394,23 @@ def test_history_combines_workflows_and_respects_feature_flags(self) -> None:
393394
response = self.get_success_response(self.organization.slug)
394395
assert [run["id"] for run in response.data] == [str(newer.id), str(older.id)]
395396

397+
def test_history_ignores_runs_of_configurable_strategies(self) -> None:
398+
v2_config = Factories.create_seer_workflow_config(
399+
self.organization, strategy=SeerWorkflowStrategy.AGENTIC_TRIAGE_V2
400+
)
401+
Factories.create_seer_workflow_run(self.organization, workflow_config=v2_config)
402+
legacy = Factories.create_seer_workflow_run(
403+
self.organization,
404+
workflow_config=SeerWorkflowConfig.get_or_create_for_strategy(
405+
self.organization.id, SeerWorkflowStrategy.AGENTIC_TRIAGE
406+
),
407+
)
408+
409+
with self.feature("organizations:seer-night-shift"):
410+
response = self.get_success_response(self.organization.slug)
411+
412+
assert [run["id"] for run in response.data] == [str(legacy.id)]
413+
396414
def test_history_hides_runs_outside_user_access(self) -> None:
397415
project = self.project
398416
workflow = self.create_agent_workflow(
@@ -429,7 +447,7 @@ def test_history_hides_runs_outside_user_access(self) -> None:
429447
assert [item["id"] for item in response.data] == [str(workflow.id)]
430448

431449
def create_agent_workflow(
432-
self, strategy: SeerWorkflowStrategy, feature_id: str
450+
self, strategy: SeerLegacyWorkflowStrategy, feature_id: str
433451
) -> SeerWorkflowRun:
434452
return create_workflow_run(
435453
SeerAgentClient(self.organization, self.user),

‎tests/sentry/seer/models/__init__.py‎

Whitespace-only changes.

0 commit comments

Comments
 (0)