Skip to content

Commit af9cb5d

Browse files
committed
refactor(agent): move preset soft delete to deleted_at mixin
1 parent 2aacc08 commit af9cb5d

18 files changed

Lines changed: 256 additions & 201 deletions

alembic/versions/32b7a1f4d9c2_add_agent_preset_archive_timestamp.py

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""add agent preset soft delete
2+
3+
Revision ID: 32b7a1f4d9c2
4+
Revises: 11d479597e08
5+
Create Date: 2026-06-28 00:00:00.000000
6+
7+
"""
8+
9+
from collections.abc import Sequence
10+
11+
import sqlalchemy as sa
12+
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "32b7a1f4d9c2"
17+
down_revision: str | None = "11d479597e08"
18+
branch_labels: str | Sequence[str] | None = None
19+
depends_on: str | Sequence[str] | None = None
20+
21+
22+
def upgrade() -> None:
23+
op.add_column(
24+
"agent_preset",
25+
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
26+
)
27+
op.drop_constraint(
28+
"uq_agent_preset_workspace_slug",
29+
"agent_preset",
30+
type_="unique",
31+
)
32+
op.create_index(
33+
"uq_agent_preset_workspace_slug_active",
34+
"agent_preset",
35+
["workspace_id", "slug"],
36+
unique=True,
37+
postgresql_where=sa.text("deleted_at IS NULL"),
38+
)
39+
40+
41+
def downgrade() -> None:
42+
bind = op.get_bind()
43+
duplicate_slug = bind.execute(
44+
sa.text(
45+
"""
46+
SELECT workspace_id, slug
47+
FROM agent_preset
48+
GROUP BY workspace_id, slug
49+
HAVING count(*) > 1
50+
LIMIT 1
51+
"""
52+
)
53+
).first()
54+
if duplicate_slug is not None:
55+
raise NotImplementedError(
56+
"Cannot downgrade after soft-deleted preset slug reuse because duplicate "
57+
"agent_preset rows would violate uq_agent_preset_workspace_slug."
58+
)
59+
60+
op.drop_index(
61+
"uq_agent_preset_workspace_slug_active",
62+
table_name="agent_preset",
63+
)
64+
op.create_unique_constraint(
65+
"uq_agent_preset_workspace_slug",
66+
"agent_preset",
67+
["workspace_id", "slug"],
68+
)
69+
op.drop_column("agent_preset", "deleted_at")

tests/unit/test_agent_channel_management_router.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ async def test_start_slack_oauth_returns_404_for_missing_token() -> None:
113113

114114

115115
@pytest.mark.anyio
116-
async def test_start_slack_oauth_returns_404_for_archived_existing_token() -> None:
116+
async def test_start_slack_oauth_returns_404_for_soft_deleted_existing_token() -> None:
117+
"""Slack OAuth updates reject tokens whose preset is soft-deleted."""
117118
role = Role(
118119
type="service",
119120
service_id="tracecat-api",
@@ -168,7 +169,10 @@ async def test_start_slack_oauth_returns_404_for_archived_existing_token() -> No
168169

169170

170171
@pytest.mark.anyio
171-
async def test_slack_oauth_callback_rejects_archived_preset_before_exchange() -> None:
172+
async def test_slack_oauth_callback_rejects_soft_deleted_preset_before_exchange() -> (
173+
None
174+
):
175+
"""Slack OAuth callbacks stop before token exchange for soft-deleted presets."""
172176
token_id = uuid.uuid4()
173177
workspace_id = uuid.uuid4()
174178
preset_id = uuid.uuid4()
@@ -258,7 +262,8 @@ def parse_stored_channel_config(self, *args, **kwargs):
258262

259263

260264
@pytest.mark.anyio
261-
async def test_update_channel_token_returns_404_for_archived_preset() -> None:
265+
async def test_update_channel_token_returns_404_for_soft_deleted_preset() -> None:
266+
"""Channel token updates surface soft-deleted presets as not found."""
262267
role = Role(
263268
type="service",
264269
service_id="tracecat-api",

tests/unit/test_agent_channel_service.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,12 @@ async def test_update_token_rejects_null_is_active(
180180

181181

182182
@pytest.mark.anyio
183-
async def test_create_token_rejects_archived_preset(
183+
async def test_create_token_rejects_soft_deleted_preset(
184184
agent_channel_service: AgentChannelService,
185185
agent_preset: AgentPreset,
186186
) -> None:
187-
agent_preset.archived_at = datetime.now(UTC)
187+
"""Channel tokens cannot be created for soft-deleted presets."""
188+
agent_preset.deleted_at = datetime.now(UTC)
188189
agent_channel_service.session.add(agent_preset)
189190
await agent_channel_service.session.commit()
190191

@@ -210,6 +211,7 @@ async def test_create_active_token_locks_preset(
210211
agent_preset: AgentPreset,
211212
monkeypatch: pytest.MonkeyPatch,
212213
) -> None:
214+
"""Creating an active token locks the preset before insertion."""
213215
captured_locks: list[bool] = []
214216
original_require_workspace_preset = agent_channel_service._require_workspace_preset
215217

@@ -248,6 +250,7 @@ async def test_create_pending_token_locks_preset(
248250
agent_preset: AgentPreset,
249251
monkeypatch: pytest.MonkeyPatch,
250252
) -> None:
253+
"""Creating a pending Slack token locks the preset before insertion."""
251254
captured_locks: list[bool] = []
252255
original_require_workspace_preset = agent_channel_service._require_workspace_preset
253256

@@ -281,10 +284,11 @@ async def instrumented_require_workspace_preset(
281284

282285

283286
@pytest.mark.anyio
284-
async def test_update_token_rejects_reactivating_archived_preset(
287+
async def test_update_token_rejects_reactivating_soft_deleted_preset(
285288
agent_channel_service: AgentChannelService,
286289
agent_preset: AgentPreset,
287290
) -> None:
291+
"""Soft-deleted presets cannot have inactive tokens reactivated."""
288292
token = await agent_channel_service.create_token(
289293
AgentChannelTokenCreate(
290294
agent_preset_id=agent_preset.id,
@@ -298,7 +302,7 @@ async def test_update_token_rejects_reactivating_archived_preset(
298302
is_active=False,
299303
)
300304
)
301-
agent_preset.archived_at = datetime.now(UTC)
305+
agent_preset.deleted_at = datetime.now(UTC)
302306
agent_channel_service.session.add(agent_preset)
303307
await agent_channel_service.session.commit()
304308

@@ -310,10 +314,11 @@ async def test_update_token_rejects_reactivating_archived_preset(
310314

311315

312316
@pytest.mark.anyio
313-
async def test_update_token_rejects_config_update_for_archived_preset(
317+
async def test_update_token_rejects_config_update_for_soft_deleted_preset(
314318
agent_channel_service: AgentChannelService,
315319
agent_preset: AgentPreset,
316320
) -> None:
321+
"""Soft-deleted presets cannot receive channel config updates."""
317322
token = await agent_channel_service.create_token(
318323
AgentChannelTokenCreate(
319324
agent_preset_id=agent_preset.id,
@@ -327,7 +332,7 @@ async def test_update_token_rejects_config_update_for_archived_preset(
327332
is_active=False,
328333
)
329334
)
330-
agent_preset.archived_at = datetime.now(UTC)
335+
agent_preset.deleted_at = datetime.now(UTC)
331336
agent_channel_service.session.add(agent_preset)
332337
await agent_channel_service.session.commit()
333338

@@ -352,6 +357,7 @@ async def test_update_token_locks_preset_when_reactivating(
352357
agent_preset: AgentPreset,
353358
monkeypatch: pytest.MonkeyPatch,
354359
) -> None:
360+
"""Reactivating a token locks the preset before mutation."""
355361
token = await agent_channel_service.create_token(
356362
AgentChannelTokenCreate(
357363
agent_preset_id=agent_preset.id,
@@ -389,10 +395,11 @@ async def instrumented_require_workspace_preset(
389395

390396

391397
@pytest.mark.anyio
392-
async def test_public_token_lookup_rejects_archived_preset(
398+
async def test_public_token_lookup_rejects_soft_deleted_preset(
393399
agent_channel_service: AgentChannelService,
394400
agent_preset: AgentPreset,
395401
) -> None:
402+
"""Public token lookup ignores tokens attached to soft-deleted presets."""
396403
token = await agent_channel_service.create_token(
397404
AgentChannelTokenCreate(
398405
agent_preset_id=agent_preset.id,
@@ -406,7 +413,7 @@ async def test_public_token_lookup_rejects_archived_preset(
406413
is_active=True,
407414
)
408415
)
409-
agent_preset.archived_at = datetime.now(UTC)
416+
agent_preset.deleted_at = datetime.now(UTC)
410417
agent_channel_service.session.add(agent_preset)
411418
await agent_channel_service.session.commit()
412419

tests/unit/test_agent_folders_service.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -254,22 +254,22 @@ async def test_move_preset_requires_agent_addons_entitlement(
254254

255255

256256
@pytest.mark.anyio
257-
async def test_move_preset_rejects_archived_preset(
257+
async def test_move_preset_rejects_soft_deleted_preset(
258258
folder_service: AgentFolderService,
259259
monkeypatch: pytest.MonkeyPatch,
260260
) -> None:
261-
"""Archived presets should not be movable through folder management."""
261+
"""Soft-deleted presets should not be movable through folder management."""
262262
monkeypatch.setattr(folder_service, "has_entitlement", AsyncMock(return_value=True))
263263
target = await folder_service.create_folder(name="target", parent_path="/")
264264
preset = AgentPreset(
265265
workspace_id=folder_service.workspace_id,
266-
name="Archived preset",
267-
slug="archived-preset",
266+
name="Soft-deleted preset",
267+
slug="soft-deleted-preset",
268268
model_name="gpt-4o-mini",
269269
model_provider="openai",
270270
retries=3,
271271
enable_internet_access=False,
272-
archived_at=datetime.now(UTC),
272+
deleted_at=datetime.now(UTC),
273273
)
274274
folder_service.session.add(preset)
275275
await folder_service.session.commit()
@@ -348,14 +348,14 @@ async def test_get_directory_items_returns_real_direct_item_counts(
348348
),
349349
AgentPreset(
350350
workspace_id=folder_service.workspace_id,
351-
name="archived",
352-
slug="archived",
351+
name="soft deleted",
352+
slug="soft-deleted",
353353
model_name="gpt-4o-mini",
354354
model_provider="openai",
355355
retries=3,
356356
enable_internet_access=False,
357357
folder_id=parent.id,
358-
archived_at=datetime.now(UTC),
358+
deleted_at=datetime.now(UTC),
359359
),
360360
]
361361
)

0 commit comments

Comments
 (0)