Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions sql/migrations/2026072502_workspace_paging_index.sql
Original file line number Diff line number Diff line change
@@ -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`);
1 change: 1 addition & 0 deletions sql/migrations/2026072502_workspace_paging_index_down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP INDEX IF EXISTS `idx_workspace_repo_paging`;
13 changes: 11 additions & 2 deletions src/internal/db/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,15 @@ pub fn builtin_migrations() -> Vec<Migration> {
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"),
),
]
}

Expand Down Expand Up @@ -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]
Expand Down
8 changes: 5 additions & 3 deletions tests/db_migration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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));
}

// ---------------------------------------------------------------------------
Expand Down
58 changes: 58 additions & 0 deletions tests/workspace_lease_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String, _>("detail").unwrap_or_default())
.collect::<Vec<_>>()
.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
Expand Down
Loading