fix(schedules): use stable handles for run schedules#349
Open
jromualdez-scale wants to merge 5 commits into
Open
fix(schedules): use stable handles for run schedules#349jromualdez-scale wants to merge 5 commits into
jromualdez-scale wants to merge 5 commits into
Conversation
Move schedule authorization and mutations to immutable row identifiers while keeping name-based aliases for convenience. Co-authored-by: Cursor <cursoragent@cursor.com>
✱ Stainless preview buildsThis PR will update the openapi python typescript Edit this comment to update them. They will appear in their respective SDK's changelogs. ✅ agentex-sdk-python studio · conflict
✅ agentex-sdk-openapi studio · code · diff
✅ agentex-sdk-typescript studio · code · diff
This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push. |
Keep name-addressed schedule authorization failures on a name-based not-found response so denied requests do not reveal the resolved schedule id. Co-authored-by: Cursor <cursoragent@cursor.com>
Allow downgrade only when no duplicate schedule names exist across rows, and fail before changing indexes when manual cleanup is required. Co-authored-by: Cursor <cursoragent@cursor.com>
7783233 to
021a6c7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/name/{name}while makingnamea mutable active-row label.Test plan
uv run pytest tests/unit/services/test_agent_run_schedule_service.py tests/unit/api/test_agent_run_schedules_authz.py tests/unit/use_cases/test_agent_run_schedules_use_case.pyMade with Cursor
Greptile Summary
This PR completes the planned fast-follow from the earlier schedules PR: schedule identity is moved from the human-readable
nameto the immutable rowid, making name a mutable label and closing the rename/auth-race the previous design could not support.schedule_id(run-schedule::{agent_id}::{schedule_id}); all existing service methods are updated from name-based to ID-based signatures with matching test coverage./name/{name}convenience aliases are added for every mutating and read route; a shared_resolve_name_alias_and_checkhelper resolves name → id, then performs the authz check on the stable id, and equalises the 404 body on both absent-name and access-denied paths so the resolved id is never leaked to an unauthorized caller.namefield onUpdateAgentRunScheduleRequest, guarded by an application-level conflict pre-check plus aDuplicateItemErrorbackstop at the DB layer.(agent_id, name)unique index with a partial index scoped to active rows (deleted_at IS NULL), making deleted names reusable; the downgrade path pre-checks for duplicate names before attempting to recreate the old constraint and raises a descriptiveRuntimeErrorif cleanup is required.Confidence Score: 5/5
Safe to merge; the auth selector migration, route expansion, and rename feature are all internally consistent and well-tested.
All primary operations (create, get, update, delete, pause, resume, trigger) now resolve through stable row IDs. The name-alias resolver correctly equalises absent-name and denied-resource error paths, preventing id/existence probing. The migration is online-safe (CONCURRENTLY with IF NOT EXISTS / IF EXISTS guards), and the downgrade proactively guards against data states that would violate the old constraint. The Temporal-before-DB ordering on updates is unchanged from the pre-existing pattern and is explicitly documented. Unit tests cover the new rename paths, the TOCTOU backstop, and the id-leak-prevention invariant.
The migration downgrade path in
2026_07_08_1626_schedule_identity_id_handles_9a4b8c7d6e5f.pydeserves a second look before any rollback attempt — it will raise if name reuse has already occurred, so a data-cleanup runbook should be prepared alongside this deploy.Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Client participant Router participant NameResolver as _resolve_name_alias_and_check participant UseCase as AgentRunSchedulesUseCase participant Service as AgentRunScheduleService participant Repo as AgentRunScheduleRepository participant Auth as AuthorizationService participant Temporal Note over Client,Temporal: ID-based route (stable handle) Client->>Router: "GET /schedules/{schedule_id}" Router->>Auth: "check(resource=schedule(selector), op=read)" Auth-->>Router: ok Router->>UseCase: get_schedule(agent_id, schedule_id) UseCase->>Service: get_schedule(agent_id, schedule_id) Service->>Repo: get_by_agent_id_and_id_or_raise(agent_id, schedule_id) Repo-->>Service: AgentRunScheduleEntity Service->>Temporal: describe_schedule(temporal_id) Temporal-->>Service: ScheduleDescription Service-->>Client: AgentRunScheduleResponse Note over Client,Temporal: Name-alias route (resolves to ID first) Client->>Router: "GET /schedules/name/{name}" Router->>NameResolver: "resolve(agent_id, name, op=read)" NameResolver->>UseCase: get_schedule_id_by_name(agent_id, name) UseCase->>Service: get_schedule_id_by_name(agent_id, name) Service->>Repo: get_by_agent_id_and_name(agent_id, name) alt schedule not found Repo-->>NameResolver: None - raise ItemDoesNotExist(name-message) NameResolver-->>Client: 404 (name-based message) else schedule found Repo-->>NameResolver: schedule_id NameResolver->>Auth: "check(resource=schedule(selector by id), op=read)" alt denied Auth-->>NameResolver: AuthorizationError NameResolver-->>Client: 404 (same name-based message, hides id) else allowed NameResolver-->>Router: schedule_id Router->>UseCase: get_schedule(agent_id, schedule_id) UseCase-->>Client: AgentRunScheduleResponse end end Note over Client,Temporal: Schedule rename Client->>Router: "PATCH /schedules/{schedule_id} name=new-name" Router->>Auth: "check(op=update)" Router->>UseCase: update_schedule(agent_id, schedule_id, request) UseCase->>Service: update_schedule(...) Service->>Repo: get_by_agent_id_and_id_or_raise Service->>Repo: get_by_agent_id_and_name(new-name) conflict check alt name conflict Service-->>Client: 400 ClientError else name available Service->>Temporal: update_schedule(temporal_id, cadence/window) Service->>Repo: update(row with new name) Repo-->>Client: AgentRunScheduleResponse end%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Client participant Router participant NameResolver as _resolve_name_alias_and_check participant UseCase as AgentRunSchedulesUseCase participant Service as AgentRunScheduleService participant Repo as AgentRunScheduleRepository participant Auth as AuthorizationService participant Temporal Note over Client,Temporal: ID-based route (stable handle) Client->>Router: "GET /schedules/{schedule_id}" Router->>Auth: "check(resource=schedule(selector), op=read)" Auth-->>Router: ok Router->>UseCase: get_schedule(agent_id, schedule_id) UseCase->>Service: get_schedule(agent_id, schedule_id) Service->>Repo: get_by_agent_id_and_id_or_raise(agent_id, schedule_id) Repo-->>Service: AgentRunScheduleEntity Service->>Temporal: describe_schedule(temporal_id) Temporal-->>Service: ScheduleDescription Service-->>Client: AgentRunScheduleResponse Note over Client,Temporal: Name-alias route (resolves to ID first) Client->>Router: "GET /schedules/name/{name}" Router->>NameResolver: "resolve(agent_id, name, op=read)" NameResolver->>UseCase: get_schedule_id_by_name(agent_id, name) UseCase->>Service: get_schedule_id_by_name(agent_id, name) Service->>Repo: get_by_agent_id_and_name(agent_id, name) alt schedule not found Repo-->>NameResolver: None - raise ItemDoesNotExist(name-message) NameResolver-->>Client: 404 (name-based message) else schedule found Repo-->>NameResolver: schedule_id NameResolver->>Auth: "check(resource=schedule(selector by id), op=read)" alt denied Auth-->>NameResolver: AuthorizationError NameResolver-->>Client: 404 (same name-based message, hides id) else allowed NameResolver-->>Router: schedule_id Router->>UseCase: get_schedule(agent_id, schedule_id) UseCase-->>Client: AgentRunScheduleResponse end end Note over Client,Temporal: Schedule rename Client->>Router: "PATCH /schedules/{schedule_id} name=new-name" Router->>Auth: "check(op=update)" Router->>UseCase: update_schedule(agent_id, schedule_id, request) UseCase->>Service: update_schedule(...) Service->>Repo: get_by_agent_id_and_id_or_raise Service->>Repo: get_by_agent_id_and_name(new-name) conflict check alt name conflict Service-->>Client: 400 ClientError else name available Service->>Temporal: update_schedule(temporal_id, cadence/window) Service->>Repo: update(row with new name) Repo-->>Client: AgentRunScheduleResponse endComments Outside Diff (1)
agentex/database/migrations/alembic/versions/2026_07_08_1626_schedule_identity_id_handles_9a4b8c7d6e5f.py, line 42-52 (link)The
downgrade()function tries to build a full (non-partial) unique index on(agent_id, name)covering all rows, including soft-deleted ones. Once the system runs under the new partial index, it becomes possible for a deleted row and an active row to share the same(agent_id, name)— for example, after a schedule is deleted and then re-created under the same name. In that stateCREATE UNIQUE INDEX CONCURRENTLY … ON agent_run_schedules (agent_id, name)will fail with a uniqueness violation, leaving the database without any(agent_id, name)uniqueness constraint while the partial index has not yet been dropped. A data-cleanup step would be needed before a safe rollback can be performed.Prompt To Fix With AI
Reviews (5): Last reviewed commit: "Merge branch 'main' into jerome/schedule..." | Re-trigger Greptile