Skip to content

fix(schedules): use stable handles for run schedules#349

Open
jromualdez-scale wants to merge 5 commits into
mainfrom
jerome/schedule-id-handles
Open

fix(schedules): use stable handles for run schedules#349
jromualdez-scale wants to merge 5 commits into
mainfrom
jerome/schedule-id-handles

Conversation

@jromualdez-scale

@jromualdez-scale jromualdez-scale commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Use immutable schedule row ids as the stable handle for run-schedule authorization and mutations.
  • Keep name-based routes as explicit convenience aliases under /name/{name} while making name a mutable active-row label.
  • Add a safe index migration so active schedule names remain unique per agent while deleted names can be reused. Addressing comment by @NiteshDhanpal here feat(schedules): agent run schedules (v1) #335 (comment)

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.py
  • Pre-commit hooks: ruff, ruff-format, OpenAPI generation, migration safety lint

Made with Cursor

Greptile Summary

This PR completes the planned fast-follow from the earlier schedules PR: schedule identity is moved from the human-readable name to the immutable row id, making name a mutable label and closing the rename/auth-race the previous design could not support.

  • Auth selector + primary routes are rebased onto 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_check helper 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.
  • Schedule rename is enabled via a new name field on UpdateAgentRunScheduleRequest, guarded by an application-level conflict pre-check plus a DuplicateItemError backstop at the DB layer.
  • Migration replaces the full (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 descriptive RuntimeError if 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.py deserves 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

Filename Overview
agentex/database/migrations/alembic/versions/2026_07_08_1626_schedule_identity_id_handles_9a4b8c7d6e5f.py Adds partial unique index on active schedules (deleted_at IS NULL) and drops the old full-table index; downgrade pre-checks for duplicate names but will raise RuntimeError after any name reuse, requiring manual data cleanup before rollback.
agentex/src/api/routes/agent_run_schedules.py Doubles route count by adding /name/{name} alias endpoints alongside the refactored /{schedule_id} primary routes; name resolution uses _resolve_name_alias_and_check which equalizes error messages on both absent-name and denied-access paths to prevent id/existence leakage.
agentex/src/domain/services/agent_run_schedule_service.py Switches auth selector key from name to immutable row id, adds get_schedule_id_by_name helper, and extends update_schedule to support name rename with a TOCTOU-safe pre-check plus DuplicateItemError catch as a backstop.
agentex/src/domain/repositories/agent_run_schedule_repository.py Adds get_by_agent_id_and_id / get_by_agent_id_and_id_or_raise for ID-keyed lookups; renames the existing name-based or_raise variant accordingly; existing get_by_agent_id_and_name is retained for name-alias resolution.
agentex/src/api/schemas/agent_run_schedules.py Adds optional name field to UpdateAgentRunScheduleRequest (enabling renames) and updates docstring/description strings to reflect that name is now a mutable label rather than an immutable natural key.
agentex/src/utils/schedule_authorization.py Adds not_found_message override parameter so name-addressed routes can substitute a name-based 404 body, preventing an unauthorized caller from distinguishing a denied resource from an absent one.
agentex/src/adapters/orm.py Updates ORM index definition to use the partial index name matching the migration and adds postgresql_where clause to enforce uniqueness only among active rows.
agentex/tests/unit/api/test_agent_run_schedules_authz.py Adds tests for the name-alias resolution flow including the denied-access case that verifies the resolved id is never echoed back to an unauthorized caller.
agentex/tests/unit/services/test_agent_run_schedule_service.py Covers new rename scenarios: allowed rename, duplicate active name rejection, and DuplicateItemError-to-ClientError conversion; also adds TestAgentRunScheduleServiceNameLookup for the new get_schedule_id_by_name helper.
agentex/tests/unit/use_cases/test_agent_run_schedules_use_case.py Adds delegation test for get_schedule_id_by_name and updates all existing tests to use schedule IDs instead of names as the primary handle.
agentex/src/domain/use_cases/agent_run_schedules_use_case.py Mechanically updates all method signatures from name-based to schedule_id-based and adds get_schedule_id_by_name delegation; no logic changes.

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
Loading
%%{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
    end
Loading

Comments Outside Diff (1)

  1. agentex/database/migrations/alembic/versions/2026_07_08_1626_schedule_identity_id_handles_9a4b8c7d6e5f.py, line 42-52 (link)

    P2 Downgrade will fail after name reuse

    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 state CREATE 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
    This is a comment left during a code review.
    Path: agentex/database/migrations/alembic/versions/2026_07_08_1626_schedule_identity_id_handles_9a4b8c7d6e5f.py
    Line: 42-52
    
    Comment:
    **Downgrade will fail after name reuse**
    
    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 state `CREATE 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.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Cursor Fix in Claude Code Fix in Codex

Reviews (5): Last reviewed commit: "Merge branch 'main' into jerome/schedule..." | Re-trigger Greptile

Move schedule authorization and mutations to immutable row identifiers while keeping name-based aliases for convenience.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jromualdez-scale jromualdez-scale marked this pull request as ready for review July 8, 2026 17:07
@jromualdez-scale jromualdez-scale requested a review from a team as a code owner July 8, 2026 17:07
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

✱ Stainless preview builds

This PR will update the agentex-sdk SDKs with the following commit messages.

openapi

feat(api): add by-name schedule methods, update schedules to use ID, add name to update request

python

feat: Use stable handles for run schedules

typescript

docs(api): clarify name field uniqueness constraint in agents schedules

Edit this comment to update them. They will appear in their respective SDK's changelogs.

agentex-sdk-python studio · conflict

Your SDK build had at least one new note diagnostic, which is a regression from the base state.

New diagnostics (12 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /agents/{agent_id}/schedules/name/{name}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `patch /agents/{agent_id}/schedules/name/{name}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /agents/{agent_id}/schedules/name/{name}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /agents/{agent_id}/schedules/{schedule_id}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `patch /agents/{agent_id}/schedules/{schedule_id}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /agents/{agent_id}/schedules/{schedule_id}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/name/{name}/trigger`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/{schedule_id}/trigger`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/name/{name}/pause`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/{schedule_id}/pause`
agentex-sdk-openapi studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ❗

New diagnostics (12 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /agents/{agent_id}/schedules/name/{name}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `patch /agents/{agent_id}/schedules/name/{name}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /agents/{agent_id}/schedules/name/{name}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /agents/{agent_id}/schedules/{schedule_id}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `patch /agents/{agent_id}/schedules/{schedule_id}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /agents/{agent_id}/schedules/{schedule_id}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/name/{name}/trigger`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/{schedule_id}/trigger`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/name/{name}/pause`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/{schedule_id}/pause`
agentex-sdk-typescript studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ❗build ✅lint ✅test ✅

npm install https://pkg.stainless.com/s/agentex-sdk-typescript/0022ddf568fabda93ddcab226d4e301ab84ffb24/dist.tar.gz
New diagnostics (12 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /agents/{agent_id}/schedules/name/{name}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `patch /agents/{agent_id}/schedules/name/{name}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /agents/{agent_id}/schedules/name/{name}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /agents/{agent_id}/schedules/{schedule_id}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `patch /agents/{agent_id}/schedules/{schedule_id}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /agents/{agent_id}/schedules/{schedule_id}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/name/{name}/trigger`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/{schedule_id}/trigger`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/name/{name}/pause`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /agents/{agent_id}/schedules/{schedule_id}/pause`

This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-07-09 00:57:56 UTC

Comment thread agentex/src/domain/services/agent_run_schedule_service.py
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>
@jromualdez-scale jromualdez-scale changed the title Use stable handles for run schedules fix(schedules): use stable handles for run schedules Jul 8, 2026
jromualdez-scale and others added 2 commits July 8, 2026 13:29
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>
@jromualdez-scale jromualdez-scale force-pushed the jerome/schedule-id-handles branch from 7783233 to 021a6c7 Compare July 8, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant