diff --git a/sql/migrations/2026072502_workspace_paging_index.sql b/sql/migrations/2026072502_workspace_paging_index.sql new file mode 100644 index 000000000..dd9aaf875 --- /dev/null +++ b/sql/migrations/2026072502_workspace_paging_index.sql @@ -0,0 +1,6 @@ +-- plan-20260714 Part C W4-s2 hardening: `agent workspace list` is a +-- bounded keyset-paginated machine surface ordered by workspace_id within the +-- current repository. The query must be an index SEARCH on the repo prefix +-- instead of scanning the whole workspace_record table as histories grow. +CREATE INDEX IF NOT EXISTS `idx_workspace_repo_paging` +ON `workspace_record` (`repo_id`, `workspace_id`); diff --git a/sql/migrations/2026072502_workspace_paging_index_down.sql b/sql/migrations/2026072502_workspace_paging_index_down.sql new file mode 100644 index 000000000..ae4e5c121 --- /dev/null +++ b/sql/migrations/2026072502_workspace_paging_index_down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS `idx_workspace_repo_paging`; diff --git a/src/internal/db/migration.rs b/src/internal/db/migration.rs index 6d1eed207..b68b08ae6 100644 --- a/src/internal/db/migration.rs +++ b/src/internal/db/migration.rs @@ -1096,6 +1096,15 @@ pub fn builtin_migrations() -> Vec { include_str!("../../../sql/migrations/2026072501_workspace_record.sql"), include_str!("../../../sql/migrations/2026072501_workspace_record_down.sql"), ), + // plan-20260714 Part C W4-s2 hardening: keyset pagination for + // `agent workspace list` must search by repository prefix in + // workspace_id order instead of scanning/sorting as the registry grows. + sql_migration( + 2026072502, + "workspace_paging_index", + include_str!("../../../sql/migrations/2026072502_workspace_paging_index.sql"), + include_str!("../../../sql/migrations/2026072502_workspace_paging_index_down.sql"), + ), ] } @@ -1341,9 +1350,9 @@ mod tests { // `builtin_migrations()` so silent registry regressions surface // here in addition to `tests/db_migration_test.rs`. let runner = builtin_runner().expect("CEX-12.5 builtin registry must build clean"); - assert_eq!(runner.len(), 42); + assert_eq!(runner.len(), 43); assert!(!runner.is_empty()); - assert_eq!(runner.max_registered_version(), Some(2026072501)); + assert_eq!(runner.max_registered_version(), Some(2026072502)); } #[test] diff --git a/tests/db_migration_test.rs b/tests/db_migration_test.rs index 7e65e70d3..0a485a0d6 100644 --- a/tests/db_migration_test.rs +++ b/tests/db_migration_test.rs @@ -52,7 +52,8 @@ fn builtin_migrations_register_current_schema_migrations() { 2026070202, 2026070301, 2026070401, 2026070501, 2026070601, 2026070701, 2026070801, 2026070802, 2026070803, 2026071301, 2026071401, 2026071402, 2026071403, 2026071404, 2026071405, 2026071406, 2026071407, 2026071901, 2026072101, 2026072201, 2026072301, - 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501 + 2026072302, 2026072303, 2026072304, 2026072401, 2026072402, 2026072403, 2026072501, + 2026072502 ] ); assert_eq!( @@ -100,13 +101,14 @@ fn builtin_migrations_register_current_schema_migrations() { "worktree_lifecycle_journal", "worktree_migrate_intent", "workspace_record", + "workspace_paging_index", ] ); let runner = builtin_runner().expect("builtin registry must build clean"); assert!(!runner.is_empty()); - assert_eq!(runner.len(), 42); - assert_eq!(runner.max_registered_version(), Some(2026072501)); + assert_eq!(runner.len(), 43); + assert_eq!(runner.max_registered_version(), Some(2026072502)); } // --------------------------------------------------------------------------- diff --git a/tests/workspace_lease_test.rs b/tests/workspace_lease_test.rs index 1f677b694..35c86fdfe 100644 --- a/tests/workspace_lease_test.rs +++ b/tests/workspace_lease_test.rs @@ -1818,6 +1818,64 @@ async fn listing_pages_without_duplicates_or_loss() { assert!(unbounded_request.items.len() <= MAX_LIST_LIMIT as usize); } +/// `agent workspace list` pages by `(repo_id, workspace_id)`, so the production +/// migration must install a matching index. Otherwise the documented cap-500 +/// machine interface can still degrade into a table scan as other repository +/// identities accumulate rows. +#[tokio::test] +async fn workspace_listing_hits_repo_paging_index() { + let db = open_db().await; + let root = db.path.parent().expect("db parent"); + let first = WorkspaceStore::acquire( + &db.conn, + &AcquireRequest::linked("wt-1", workspace_dir(root, "ws-1"), "owner-1", TTL), + NOW, + ) + .await + .expect("first workspace"); + let _second = WorkspaceStore::acquire( + &db.conn, + &AcquireRequest::linked("wt-2", workspace_dir(root, "ws-2"), "owner-2", TTL), + NOW + 1, + ) + .await + .expect("second workspace"); + + let rows = db + .conn + .query_all(Statement::from_sql_and_values( + DbBackend::Sqlite, + "EXPLAIN QUERY PLAN \ + SELECT workspace_id, repo_id, kind, worktree_id, path, owner_kind, owner_id, \ + task_id, session_id, base_commit, branch, state, lease_owner, lease_fence, \ + lease_expires_at, created_at, updated_at \ + FROM workspace_record WHERE repo_id = ? AND workspace_id > ? \ + ORDER BY workspace_id ASC LIMIT ?", + [REPO_ID.into(), first.workspace_id.into(), 51i64.into()], + )) + .await + .expect("explain workspace pagination"); + let plan = rows + .iter() + .map(|row| row.try_get_by::("detail").unwrap_or_default()) + .collect::>() + .join("\n"); + assert!( + plan.contains("idx_workspace_repo_paging"), + "workspace pagination must use idx_workspace_repo_paging, got:\n{plan}" + ); + assert!( + !plan.contains("TEMP B-TREE"), + "workspace pagination must not sort via temp B-tree, got:\n{plan}" + ); + for line in plan.lines() { + assert!( + !line.trim_start().starts_with("SCAN workspace_record") || line.contains("USING"), + "workspace pagination must not full-scan, got:\n{plan}" + ); + } +} + /// The registry stores association IDs only — no prompts, transcripts, or tool /// payloads may ever leak into it (§C.8: "path、prompt、transcript、tool /// payload 不进入该表"). Pin the column set so a future column has to argue