feat(ui): add scheduled task management - #354
Conversation
✱ 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-openapi studio · code · diff
✅ agentex-sdk-typescript studio · code · diff
✅ agentex-sdk-python studio · conflict
This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push. |
8445f04 to
0673c3d
Compare
Co-authored-by: Cursor <cursoragent@cursor.com>
Expose task-backed run counts and exact skip/unskip operations so schedule clients can reason about individual future occurrences. Co-authored-by: Cursor <cursoragent@cursor.com>
Add agent scoping, upcoming occurrence timelines, active toggles, run-now confirmation, and skipped occurrence controls to the scheduled tasks page. Co-authored-by: Cursor <cursoragent@cursor.com>
Use schedule metadata for scheduled-run task labels and add a calendar indicator so recurring runs are easier to recognize. Co-authored-by: Cursor <cursoragent@cursor.com>
Use the schedule name as the task display label and keep occurrence timing in metadata for views that need it. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
0673c3d to
e36d4cd
Compare
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Hide the scheduled tasks UI unless the schedules API is enabled so users do not navigate to endpoints that are absent at runtime. Co-authored-by: Cursor <cursoragent@cursor.com>
declan-scale
left a comment
There was a problem hiding this comment.
Ran locally, looks good, just some organization / small ui improvements I'd suggest
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Thanks for the great comments @declan-scale I addressed them all |
Co-authored-by: Cursor <cursoragent@cursor.com>
…+ two-factor) (scaleapi#252) ## Summary Wires Spark AuthZ into all six `agent_api_keys` routes. Mirrors Asher's task-route pattern from [scaleapi#249](scaleapi#249). - All denials on the api_key resource collapse to `404` so callers can't probe cross-tenant existence. - `GET` list filters to api_keys the caller can read (id filter pushed into the repo for correct pagination). - `POST` does an explicit `agent.update` check on the parent — only enforcement surface at create time, since no api_key resource exists yet. - Mutations (`delete`) rely on SpiceDB's transitive `api_key.delete = editor & parent_agent->update & tenant_gate` expansion. The `parent_agent` edge is populated by [scaleapi#248](scaleapi#248 `register_resource(parent=agent)`. ## Stack | Repo | PR | State | |---|---|---| | scaleapi/scaleapi | [#144657](scaleapi/scaleapi#144657) — sgp-authz 0.7.0 | ✅ Merged | | scaleapi/agentex | [scaleapi#354](scaleapi/agentex#354) — `api_key` mapping | ✅ Merged | | scaleapi/scale-agentex | [scaleapi#248](scaleapi#248) — dual-write | ✅ Merged | | scaleapi/scale-agentex | this PR — route-layer FGAC | Ready | Linear: [AGX1-263](https://linear.app/scale-epd/issue/AGX1-263). ## Tests - `tests/unit/api/test_agent_api_keys_authz.py` — 12 tests covering the collapse helper, `DAuthorizedId` routing, name-route inline collapse, list filtering, and create-parent-check. ## Out of scope - Restoring the 403/404 split once api_keys carry tenant scope ([AGX1-290](https://linear.app/scale-epd/issue/AGX1-290)). <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR wires Spark AuthZ (FGAC) into all six `agent_api_keys` routes, mirroring the task-route pattern from scaleapi#249. All authorization denials collapse to `404` to prevent cross-tenant key-name probing, and the delete-by-name route now resolves name→id before checking auth (closing the TOCTOU window flagged in a prior review). - **All six routes gated**: `POST` checks `agent.update` on the parent agent (no api_key resource exists yet); `GET /{id}` and `DELETE /{id}` use `DAuthorizedId`; `GET /name/{name}` and `DELETE /name/{name}` use the inline `_check_api_key_or_collapse_to_404` helper; `GET /` uses `DAuthorizedResourceIds` with the id list pushed into the SQL filter for correct pagination. - **404 collapse**: Both \"row absent\" and \"row present but denied\" paths on name routes now raise `ItemDoesNotExist(API_KEY_NOT_FOUND_MESSAGE)`, making the bodies byte-for-byte identical and closing the existence-leak vector. - **`AgentAPIKeysUseCase.list`** gains an optional `id` filter with an explicit empty-list short-circuit to prevent the base repo from converting `id=[]` into an unfiltered query. <details><summary><h3>Confidence Score: 5/5</h3></summary> The FGAC wiring is correct across all six routes; the 404-collapse and TOCTOU fixes flagged in prior reviews have been addressed. The authorization logic is sound: denials are consistently collapsed to 404 with identical bodies, delete-by-name now deletes by resolved ID rather than by name, and the empty-list short-circuit prevents an accidental unfiltered query. The only findings are a misleading (but vacuous) test assertion and an untyped parameter — neither affects runtime behavior. The test assertion in test_delete_by_name_handler_collapses_denial_to_404 checks a removed method rather than the one now used for deletion; worth a quick fix before the tests are relied upon for regression coverage. </details> <h3>Important Files Changed</h3> | Filename | Overview | |----------|----------| | agentex/src/api/routes/agent_api_keys.py | All six routes now gated by FGAC; denials collapse to 404; delete-by-name resolves name→id before auth check, closing the TOCTOU window; absent and denied 404 bodies are byte-for-byte identical via API_KEY_NOT_FOUND_MESSAGE. | | agentex/src/utils/agent_api_key_authorization.py | New collapse helper correctly catches AuthorizationError and raises ItemDoesNotExist with a fixed message; minor: authorization parameter is untyped. | | agentex/src/utils/authorization_shortcuts.py | DAuthorizedId gains an api_key branch that delegates to the collapse helper; formatting-only change to DAuthorizedBodyId; no logic issues. | | agentex/src/domain/use_cases/agent_api_keys_use_case.py | list() gains optional id filter pushed to repo layer; empty-list short-circuit correctly prevents unfiltered query when caller has no authorized keys. | | agentex/tests/unit/api/test_agent_api_keys_authz.py | 12 tests covering all major FGAC paths; test_delete_by_name_handler_collapses_denial_to_404 asserts the removed delete_by_agent_id_and_key_name is not called rather than the new delete method, making the delete-not-invoked assertion vacuous. | | agentex/src/api/schemas/authorization_types.py | Formatting-only change to the schedule() method signature; no logic changes. | </details> <details><summary><h3>Sequence Diagram</h3></summary> ```mermaid sequenceDiagram participant Client participant Route as agent_api_keys route participant SpiceDB as SpiceDB (AuthZ) participant DB as Postgres (repo) Note over Route,DB: GET /{id} / DELETE /{id} — DAuthorizedId dep Client->>Route: "GET /agent_api_keys/{id}" Route->>SpiceDB: "check(api_key:{id}, read)" alt denied SpiceDB-->>Route: AuthorizationError Route-->>Client: 404 ItemDoesNotExist else allowed SpiceDB-->>Route: OK Route->>DB: get(id) DB-->>Route: entity Route-->>Client: 200 AgentAPIKey end Note over Route,DB: GET /name/{name} — inline collapse Client->>Route: "GET /agent_api_keys/name/{name}" Route->>DB: get_by_agent_id_and_name(...) alt absent DB-->>Route: None Route-->>Client: 404 Agent api_key not found. else present DB-->>Route: entity (with id) Route->>SpiceDB: "check(api_key:{id}, read)" alt denied SpiceDB-->>Route: AuthorizationError Route-->>Client: 404 Agent api_key not found. else allowed SpiceDB-->>Route: OK Route-->>Client: 200 AgentAPIKey end end Note over Route,DB: DELETE /name/{name} — resolve→check→delete Client->>Route: "DELETE /agent_api_keys/name/{name}" Route->>DB: get_by_agent_id_and_name(...) alt absent DB-->>Route: None Route-->>Client: 404 Agent api_key not found. else present DB-->>Route: entity (with id) Route->>SpiceDB: "check(api_key:{id}, delete)" alt denied SpiceDB-->>Route: AuthorizationError Route-->>Client: 404 Agent api_key not found. else allowed SpiceDB-->>Route: OK Route->>DB: "delete(id=entity.id)" Route-->>Client: 200 deleted end end Note over Route,DB: POST / — parent agent.update check Client->>Route: POST /agent_api_keys Route->>DB: agent_use_case.get(...) Route->>SpiceDB: "check(agent:{id}, update)" alt denied SpiceDB-->>Route: AuthorizationError 403 Route-->>Client: 403 else allowed SpiceDB-->>Route: OK Route->>DB: create api_key Route-->>Client: 201 CreateAPIKeyResponse end ``` </details> <!-- greptile_failed_comments --> <details open><summary><h3>Comments Outside Diff (2)</h3></summary> 1. `agentex/src/api/routes/agent_api_keys.py`, line 163-179 ([link](https://github.com/scaleapi/scale-agentex/blob/4335ca721893fc121ba8209b05ee1dcc6ef9d7b5/agentex/src/api/routes/agent_api_keys.py#L163-L179)) <a href="#"><img alt="P1" src="https://greptile-static-assets.s3.amazonaws.com/badges/p1.svg?v=9" align="top"></a> <a href="#"><img alt="security" src="https://greptile-static-assets.s3.amazonaws.com/badges/Security.svg?v=2" align="top"></a> **Distinguishable 404 bodies defeat the existence-leak collapse** The name routes produce two structurally different 404 responses depending on whether the key exists in the database. If the key is absent, `HTTPException` is raised with `"Agent api_key '{name}' not found for agent ID {agent.id}"`. If the key exists but the caller is denied, `ItemDoesNotExist` is raised with `"Item with id '{api_key_id}' does not exist."`. Both return HTTP 404, but a cross-tenant caller who can resolve an `agent_id` can probe for key-name existence by comparing the response body — exactly the information leak the collapse is meant to prevent. The same issue exists in `delete_agent_api_key_by_name` (line ~264). To close it, both "not found" paths should produce an identical detail string (e.g. both raise `ItemDoesNotExist` with the same generic message). <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: agentex/src/api/routes/agent_api_keys.py Line: 163-179 Comment: **Distinguishable 404 bodies defeat the existence-leak collapse** The name routes produce two structurally different 404 responses depending on whether the key exists in the database. If the key is absent, `HTTPException` is raised with `"Agent api_key '{name}' not found for agent ID {agent.id}"`. If the key exists but the caller is denied, `ItemDoesNotExist` is raised with `"Item with id '{api_key_id}' does not exist."`. Both return HTTP 404, but a cross-tenant caller who can resolve an `agent_id` can probe for key-name existence by comparing the response body — exactly the information leak the collapse is meant to prevent. The same issue exists in `delete_agent_api_key_by_name` (line ~264). To close it, both "not found" paths should produce an identical detail string (e.g. both raise `ItemDoesNotExist` with the same generic message). How can I resolve this? If you propose a fix, please make it concise. ````` </details> <a href="https://app.greptile.com/api/ide/cursor?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20163-179%0A%0AComment%3A%0A**Distinguishable%20404%20bodies%20defeat%20the%20existence-leak%20collapse**%0A%0AThe%20name%20routes%20produce%20two%20structurally%20different%20404%20responses%20depending%20on%20whether%20the%20key%20exists%20in%20the%20database.%20If%20the%20key%20is%20absent%2C%20%60HTTPException%60%20is%20raised%20with%20%60%22Agent%20api_key%20'%7Bname%7D'%20not%20found%20for%20agent%20ID%20%7Bagent.id%7D%22%60.%20If%20the%20key%20exists%20but%20the%20caller%20is%20denied%2C%20%60ItemDoesNotExist%60%20is%20raised%20with%20%60%22Item%20with%20id%20'%7Bapi_key_id%7D'%20does%20not%20exist.%22%60.%20Both%20return%20HTTP%20404%2C%20but%20a%20cross-tenant%20caller%20who%20can%20resolve%20an%20%60agent_id%60%20can%20probe%20for%20key-name%20existence%20by%20comparing%20the%20response%20body%20%E2%80%94%20exactly%20the%20information%20leak%20the%20collapse%20is%20meant%20to%20prevent.%20The%20same%20issue%20exists%20in%20%60delete_agent_api_key_by_name%60%20%28line%20~264%29.%20To%20close%20it%2C%20both%20%22not%20found%22%20paths%20should%20produce%20an%20identical%20detail%20string%20%28e.g.%20both%20raise%20%60ItemDoesNotExist%60%20with%20the%20same%20generic%20message%29.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&pr=252&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursorDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3"><img alt="Fix in Cursor" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3" height="20"></picture></a> <a href="https://app.greptile.com/ide/claude-code?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20163-179%0A%0AComment%3A%0A**Distinguishable%20404%20bodies%20defeat%20the%20existence-leak%20collapse**%0A%0AThe%20name%20routes%20produce%20two%20structurally%20different%20404%20responses%20depending%20on%20whether%20the%20key%20exists%20in%20the%20database.%20If%20the%20key%20is%20absent%2C%20%60HTTPException%60%20is%20raised%20with%20%60%22Agent%20api_key%20'%7Bname%7D'%20not%20found%20for%20agent%20ID%20%7Bagent.id%7D%22%60.%20If%20the%20key%20exists%20but%20the%20caller%20is%20denied%2C%20%60ItemDoesNotExist%60%20is%20raised%20with%20%60%22Item%20with%20id%20'%7Bapi_key_id%7D'%20does%20not%20exist.%22%60.%20Both%20return%20HTTP%20404%2C%20but%20a%20cross-tenant%20caller%20who%20can%20resolve%20an%20%60agent_id%60%20can%20probe%20for%20key-name%20existence%20by%20comparing%20the%20response%20body%20%E2%80%94%20exactly%20the%20information%20leak%20the%20collapse%20is%20meant%20to%20prevent.%20The%20same%20issue%20exists%20in%20%60delete_agent_api_key_by_name%60%20%28line%20~264%29.%20To%20close%20it%2C%20both%20%22not%20found%22%20paths%20should%20produce%20an%20identical%20detail%20string%20%28e.g.%20both%20raise%20%60ItemDoesNotExist%60%20with%20the%20same%20generic%20message%29.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&repo=scaleapi%2Fscale-agentex&pr=252&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaudeDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3"><img alt="Fix in Claude Code" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3" height="20"></picture></a> <a href="https://chatgpt.com/codex/deeplink?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%0A%0AThis%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20163-179%0A%0AComment%3A%0A**Distinguishable%20404%20bodies%20defeat%20the%20existence-leak%20collapse**%0A%0AThe%20name%20routes%20produce%20two%20structurally%20different%20404%20responses%20depending%20on%20whether%20the%20key%20exists%20in%20the%20database.%20If%20the%20key%20is%20absent%2C%20%60HTTPException%60%20is%20raised%20with%20%60%22Agent%20api_key%20'%7Bname%7D'%20not%20found%20for%20agent%20ID%20%7Bagent.id%7D%22%60.%20If%20the%20key%20exists%20but%20the%20caller%20is%20denied%2C%20%60ItemDoesNotExist%60%20is%20raised%20with%20%60%22Item%20with%20id%20'%7Bapi_key_id%7D'%20does%20not%20exist.%22%60.%20Both%20return%20HTTP%20404%2C%20but%20a%20cross-tenant%20caller%20who%20can%20resolve%20an%20%60agent_id%60%20can%20probe%20for%20key-name%20existence%20by%20comparing%20the%20response%20body%20%E2%80%94%20exactly%20the%20information%20leak%20the%20collapse%20is%20meant%20to%20prevent.%20The%20same%20issue%20exists%20in%20%60delete_agent_api_key_by_name%60%20%28line%20~264%29.%20To%20close%20it%2C%20both%20%22not%20found%22%20paths%20should%20produce%20an%20identical%20detail%20string%20%28e.g.%20both%20raise%20%60ItemDoesNotExist%60%20with%20the%20same%20generic%20message%29.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise."><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodexDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3"><img alt="Fix in Codex" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3" height="20"></picture></a> 2. `agentex/src/api/routes/agent_api_keys.py`, line 260-278 ([link](https://github.com/scaleapi/scale-agentex/blob/4335ca721893fc121ba8209b05ee1dcc6ef9d7b5/agentex/src/api/routes/agent_api_keys.py#L260-L278)) <a href="#"><img alt="P2" src="https://greptile-static-assets.s3.amazonaws.com/badges/p2.svg?v=9" align="top"></a> **TOCTOU window between auth check and delete-by-name** The auth check resolves the key to an `id` and passes that ID to SpiceDB, but the subsequent delete is issued by name (`delete_by_agent_id_and_key_name`). If the key is deleted and a new key with the same name is created between the lookup and the delete, the auth check will have evaluated the old key's ID (which the caller may have legitimately had delete rights on) but the mutation will affect the new key. The practical risk is low, but it is a real race between a non-atomic read-auth-mutate sequence. Deleting by the resolved `id` instead of by name would close it. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: agentex/src/api/routes/agent_api_keys.py Line: 260-278 Comment: **TOCTOU window between auth check and delete-by-name** The auth check resolves the key to an `id` and passes that ID to SpiceDB, but the subsequent delete is issued by name (`delete_by_agent_id_and_key_name`). If the key is deleted and a new key with the same name is created between the lookup and the delete, the auth check will have evaluated the old key's ID (which the caller may have legitimately had delete rights on) but the mutation will affect the new key. The practical risk is low, but it is a real race between a non-atomic read-auth-mutate sequence. Deleting by the resolved `id` instead of by name would close it. How can I resolve this? If you propose a fix, please make it concise. ````` </details> <a href="https://app.greptile.com/api/ide/cursor?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20260-278%0A%0AComment%3A%0A**TOCTOU%20window%20between%20auth%20check%20and%20delete-by-name**%0A%0AThe%20auth%20check%20resolves%20the%20key%20to%20an%20%60id%60%20and%20passes%20that%20ID%20to%20SpiceDB%2C%20but%20the%20subsequent%20delete%20is%20issued%20by%20name%20%28%60delete_by_agent_id_and_key_name%60%29.%20If%20the%20key%20is%20deleted%20and%20a%20new%20key%20with%20the%20same%20name%20is%20created%20between%20the%20lookup%20and%20the%20delete%2C%20the%20auth%20check%20will%20have%20evaluated%20the%20old%20key's%20ID%20%28which%20the%20caller%20may%20have%20legitimately%20had%20delete%20rights%20on%29%20but%20the%20mutation%20will%20affect%20the%20new%20key.%20The%20practical%20risk%20is%20low%2C%20but%20it%20is%20a%20real%20race%20between%20a%20non-atomic%20read-auth-mutate%20sequence.%20Deleting%20by%20the%20resolved%20%60id%60%20instead%20of%20by%20name%20would%20close%20it.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&pr=252&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursorDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3"><img alt="Fix in Cursor" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCursor.svg?v=3" height="20"></picture></a> <a href="https://app.greptile.com/ide/claude-code?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20260-278%0A%0AComment%3A%0A**TOCTOU%20window%20between%20auth%20check%20and%20delete-by-name**%0A%0AThe%20auth%20check%20resolves%20the%20key%20to%20an%20%60id%60%20and%20passes%20that%20ID%20to%20SpiceDB%2C%20but%20the%20subsequent%20delete%20is%20issued%20by%20name%20%28%60delete_by_agent_id_and_key_name%60%29.%20If%20the%20key%20is%20deleted%20and%20a%20new%20key%20with%20the%20same%20name%20is%20created%20between%20the%20lookup%20and%20the%20delete%2C%20the%20auth%20check%20will%20have%20evaluated%20the%20old%20key's%20ID%20%28which%20the%20caller%20may%20have%20legitimately%20had%20delete%20rights%20on%29%20but%20the%20mutation%20will%20affect%20the%20new%20key.%20The%20practical%20risk%20is%20low%2C%20but%20it%20is%20a%20real%20race%20between%20a%20non-atomic%20read-auth-mutate%20sequence.%20Deleting%20by%20the%20resolved%20%60id%60%20instead%20of%20by%20name%20would%20close%20it.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&repo=scaleapi%2Fscale-agentex&pr=252&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaudeDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3"><img alt="Fix in Claude Code" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=3" height="20"></picture></a> <a href="https://chatgpt.com/codex/deeplink?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%0A%0AThis%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20agentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%0ALine%3A%20260-278%0A%0AComment%3A%0A**TOCTOU%20window%20between%20auth%20check%20and%20delete-by-name**%0A%0AThe%20auth%20check%20resolves%20the%20key%20to%20an%20%60id%60%20and%20passes%20that%20ID%20to%20SpiceDB%2C%20but%20the%20subsequent%20delete%20is%20issued%20by%20name%20%28%60delete_by_agent_id_and_key_name%60%29.%20If%20the%20key%20is%20deleted%20and%20a%20new%20key%20with%20the%20same%20name%20is%20created%20between%20the%20lookup%20and%20the%20delete%2C%20the%20auth%20check%20will%20have%20evaluated%20the%20old%20key's%20ID%20%28which%20the%20caller%20may%20have%20legitimately%20had%20delete%20rights%20on%29%20but%20the%20mutation%20will%20affect%20the%20new%20key.%20The%20practical%20risk%20is%20low%2C%20but%20it%20is%20a%20real%20race%20between%20a%20non-atomic%20read-auth-mutate%20sequence.%20Deleting%20by%20the%20resolved%20%60id%60%20instead%20of%20by%20name%20would%20close%20it.%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise."><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodexDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3"><img alt="Fix in Codex" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInCodex.svg?v=3" height="20"></picture></a> </details> <!-- /greptile_failed_comments --> <a href="https://app.greptile.com/api/ide/cursor?prompt=Fix%20the%20following%202%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%202%0Aagentex%2Ftests%2Funit%2Fapi%2Ftest_agent_api_keys_authz.py%3A195-213%0A**Vacuous%20assertion%20on%20removed%20method**%0A%0A%60api_key_use_case.delete_by_agent_id_and_key_name.assert_not_called%28%29%60%20is%20dead%20code%20%E2%80%94%20%60delete_agent_api_key_by_name%60%20no%20longer%20calls%20%60delete_by_agent_id_and_key_name%60%3B%20it%20calls%20%60agent_api_key_use_case.delete%28id%3D...%29%60.%20The%20comment%20says%20%22the%20delete%20is%20NOT%20invoked%20when%20the%20check%20fails%2C%22%20but%20the%20assertion%20doesn't%20test%20that.%20If%20someone%20accidentally%20removed%20the%20%60_check_api_key_or_collapse_to_404%60%20guard%2C%20this%20assertion%20would%20still%20pass.%20Add%20%60api_key_use_case.delete%20%3D%20AsyncMock%28%29%60%20in%20the%20setup%20and%20assert%20%60api_key_use_case.delete.assert_not_called%28%29%60%20to%20test%20the%20intended%20safety%20property.%0A%0A%23%23%23%20Issue%202%20of%202%0Aagentex%2Fsrc%2Futils%2Fagent_api_key_authorization.py%3A13-17%0AMissing%20type%20annotation%20on%20%60authorization%60%20parameter%20%E2%80%94%20the%20other%20parameters%20are%20typed%20but%20this%20one%20isn't%2C%20so%20type-checkers%20won't%20catch%20callers%20passing%20the%20wrong%20type.%20The%20concrete%20type%20is%20%60DAuthorizationService%60%20%28matching%20every%20call%20site%20in%20this%20PR%29.%0A%0A%60%60%60suggestion%0Aasync%20def%20_check_api_key_or_collapse_to_404%28%0A%20%20%20%20authorization%3A%20%22DAuthorizationService%22%2C%0A%20%20%20%20api_key_id%3A%20str%2C%0A%20%20%20%20operation%3A%20AuthorizedOperationType%2C%0A%29%20-%3E%20None%3A%0A%60%60%60%0A%0A&pr=252&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursorDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=3"><img alt="Fix All in Cursor" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=3" height="20"></picture></a> <a href="https://app.greptile.com/ide/claude-code?prompt=Fix%20the%20following%202%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%202%0Aagentex%2Ftests%2Funit%2Fapi%2Ftest_agent_api_keys_authz.py%3A195-213%0A**Vacuous%20assertion%20on%20removed%20method**%0A%0A%60api_key_use_case.delete_by_agent_id_and_key_name.assert_not_called%28%29%60%20is%20dead%20code%20%E2%80%94%20%60delete_agent_api_key_by_name%60%20no%20longer%20calls%20%60delete_by_agent_id_and_key_name%60%3B%20it%20calls%20%60agent_api_key_use_case.delete%28id%3D...%29%60.%20The%20comment%20says%20%22the%20delete%20is%20NOT%20invoked%20when%20the%20check%20fails%2C%22%20but%20the%20assertion%20doesn't%20test%20that.%20If%20someone%20accidentally%20removed%20the%20%60_check_api_key_or_collapse_to_404%60%20guard%2C%20this%20assertion%20would%20still%20pass.%20Add%20%60api_key_use_case.delete%20%3D%20AsyncMock%28%29%60%20in%20the%20setup%20and%20assert%20%60api_key_use_case.delete.assert_not_called%28%29%60%20to%20test%20the%20intended%20safety%20property.%0A%0A%23%23%23%20Issue%202%20of%202%0Aagentex%2Fsrc%2Futils%2Fagent_api_key_authorization.py%3A13-17%0AMissing%20type%20annotation%20on%20%60authorization%60%20parameter%20%E2%80%94%20the%20other%20parameters%20are%20typed%20but%20this%20one%20isn't%2C%20so%20type-checkers%20won't%20catch%20callers%20passing%20the%20wrong%20type.%20The%20concrete%20type%20is%20%60DAuthorizationService%60%20%28matching%20every%20call%20site%20in%20this%20PR%29.%0A%0A%60%60%60suggestion%0Aasync%20def%20_check_api_key_or_collapse_to_404%28%0A%20%20%20%20authorization%3A%20%22DAuthorizationService%22%2C%0A%20%20%20%20api_key_id%3A%20str%2C%0A%20%20%20%20operation%3A%20AuthorizedOperationType%2C%0A%29%20-%3E%20None%3A%0A%60%60%60%0A%0A&repo=scaleapi%2Fscale-agentex&pr=252&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaudeDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=3"><img alt="Fix All in Claude Code" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=3" height="20"></picture></a> <a href="https://chatgpt.com/codex/deeplink?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22dhruv%2Fagx1-263-agent-api-keys-route-migration%22.%0A%0AFix%20the%20following%202%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%202%0Aagentex%2Ftests%2Funit%2Fapi%2Ftest_agent_api_keys_authz.py%3A195-213%0A**Vacuous%20assertion%20on%20removed%20method**%0A%0A%60api_key_use_case.delete_by_agent_id_and_key_name.assert_not_called%28%29%60%20is%20dead%20code%20%E2%80%94%20%60delete_agent_api_key_by_name%60%20no%20longer%20calls%20%60delete_by_agent_id_and_key_name%60%3B%20it%20calls%20%60agent_api_key_use_case.delete%28id%3D...%29%60.%20The%20comment%20says%20%22the%20delete%20is%20NOT%20invoked%20when%20the%20check%20fails%2C%22%20but%20the%20assertion%20doesn't%20test%20that.%20If%20someone%20accidentally%20removed%20the%20%60_check_api_key_or_collapse_to_404%60%20guard%2C%20this%20assertion%20would%20still%20pass.%20Add%20%60api_key_use_case.delete%20%3D%20AsyncMock%28%29%60%20in%20the%20setup%20and%20assert%20%60api_key_use_case.delete.assert_not_called%28%29%60%20to%20test%20the%20intended%20safety%20property.%0A%0A%23%23%23%20Issue%202%20of%202%0Aagentex%2Fsrc%2Futils%2Fagent_api_key_authorization.py%3A13-17%0AMissing%20type%20annotation%20on%20%60authorization%60%20parameter%20%E2%80%94%20the%20other%20parameters%20are%20typed%20but%20this%20one%20isn't%2C%20so%20type-checkers%20won't%20catch%20callers%20passing%20the%20wrong%20type.%20The%20concrete%20type%20is%20%60DAuthorizationService%60%20%28matching%20every%20call%20site%20in%20this%20PR%29.%0A%0A%60%60%60suggestion%0Aasync%20def%20_check_api_key_or_collapse_to_404%28%0A%20%20%20%20authorization%3A%20%22DAuthorizationService%22%2C%0A%20%20%20%20api_key_id%3A%20str%2C%0A%20%20%20%20operation%3A%20AuthorizedOperationType%2C%0A%29%20-%3E%20None%3A%0A%60%60%60%0A%0A"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=3"><img alt="Fix All in Codex" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=3" height="20"></picture></a> <details><summary>Prompt To Fix All With AI</summary> `````markdown Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes. --- ### Issue 1 of 2 agentex/tests/unit/api/test_agent_api_keys_authz.py:195-213 **Vacuous assertion on removed method** `api_key_use_case.delete_by_agent_id_and_key_name.assert_not_called()` is dead code — `delete_agent_api_key_by_name` no longer calls `delete_by_agent_id_and_key_name`; it calls `agent_api_key_use_case.delete(id=...)`. The comment says "the delete is NOT invoked when the check fails," but the assertion doesn't test that. If someone accidentally removed the `_check_api_key_or_collapse_to_404` guard, this assertion would still pass. Add `api_key_use_case.delete = AsyncMock()` in the setup and assert `api_key_use_case.delete.assert_not_called()` to test the intended safety property. ### Issue 2 of 2 agentex/src/utils/agent_api_key_authorization.py:13-17 Missing type annotation on `authorization` parameter — the other parameters are typed but this one isn't, so type-checkers won't catch callers passing the wrong type. The concrete type is `DAuthorizationService` (matching every call site in this PR). ```suggestion async def _check_api_key_or_collapse_to_404( authorization: "DAuthorizationService", api_key_id: str, operation: AuthorizedOperationType, ) -> None: ``` ````` </details> <sub>Reviews (4): Last reviewed commit: ["refactor(AGX1-263): close delete-by-name..."](scaleapi@29a822a) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=34413167)</sub> <!-- /greptile_comment --> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
UX details
Test plan
npm --prefix agentex-ui run typechecknpm --prefix agentex-ui run lintnpm --prefix agentex-ui run test:run -- hooks/use-safe-search-params.test.tsx lib/agent-run-schedules.test.ts lib/schedule-utils.test.tsScreen.Recording.2026-07-13.at.2.25.20.PM.mov
Greptile Summary
This PR delivers a full Scheduled Tasks UI surface: inline schedule creation with animated feedback, an Upcoming timeline grouped by Today/Tomorrow/Later, a Schedules list with pause/resume toggle, skip/unskip per-occurrence, run-now flows, edit and delete modals, and a feature flag (
ENABLE_AGENT_RUN_SCHEDULES) that gates the entire experience.agentex-ui/components/scheduled-tasks/module (~1 400 lines) provides the page, composer, cadence picker, upcoming list, schedules list, modals, and shared helpers; routing is handled entirely via URL search params (view,schedule_scope,agent_name).lib/schedule-utils.tsowns cadence serialization, validation, display, and thescheduleToCadenceround-trip used by the edit form.agentex/src/temporal/activities/scheduled_agent_run_activities.pyremoves_format_fire_timeand the per-occurrence timestamp fromtask_metadata.display_name, keeping task names stable across retries.Confidence Score: 5/5
Safe to merge; the feature is flag-gated and all CRUD flows, cache invalidation, and error paths look correct.
Additive, flag-gated change. TanStack Query cache keys are well-structured, mutation error handling surfaces toasts via onError, and search-param routing clears stale VIEW values on task selection. The two findings only affect externally-created sub-minute or complex-cron schedules that the UI itself never produces.
agentex-ui/lib/schedule-utils.ts — specifically scheduleToCadence (sub-minute round-trip) and describeCadence (missing complex-cron fallback).
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Page Load] --> B{agentRunSchedulesEnabled?} B -->|false| C[Chat / Home only] B -->|true| D{view=scheduled_tasks?} D -->|no| E{taskID param?} E -->|yes| F[ChatView] E -->|no| G[HomeView] D -->|yes| H[ScheduledTasksPage] H --> I{scheduleScope?} I -->|ALL| J[useAgentRunSchedulesForAgents all agents] I -->|CURRENT| K{agent selected?} K -->|no| L[Empty State: Select an agent] K -->|yes| M[useAgentRunSchedules current agent] J --> N[useAgentRunScheduleDetailsForItems one req per schedule] M --> N N --> O{scheduleView?} O -->|upcoming| P[UpcomingScheduleList Today/Tomorrow/Later] O -->|all| Q[AllSchedulesList sorted by next run] M --> R[ScheduleComposer create new schedule]%%{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"}}}%% flowchart TD A[Page Load] --> B{agentRunSchedulesEnabled?} B -->|false| C[Chat / Home only] B -->|true| D{view=scheduled_tasks?} D -->|no| E{taskID param?} E -->|yes| F[ChatView] E -->|no| G[HomeView] D -->|yes| H[ScheduledTasksPage] H --> I{scheduleScope?} I -->|ALL| J[useAgentRunSchedulesForAgents all agents] I -->|CURRENT| K{agent selected?} K -->|no| L[Empty State: Select an agent] K -->|yes| M[useAgentRunSchedules current agent] J --> N[useAgentRunScheduleDetailsForItems one req per schedule] M --> N N --> O{scheduleView?} O -->|upcoming| P[UpcomingScheduleList Today/Tomorrow/Later] O -->|all| Q[AllSchedulesList sorted by next run] M --> R[ScheduleComposer create new schedule]Reviews (8): Last reviewed commit: "Merge branch 'main' into jerome/schedule..." | Re-trigger Greptile