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
23 changes: 21 additions & 2 deletions docs/architecture/extensions/external-ai-work-sources-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,21 @@ Zcode 会话读取不意味着其 ACP 运行时、设置、Skill、MCP 或 Hook
工作区归属按来源目录匹配最近的已登记本地工作区。后台任务不依赖控制端在线;会话元数据分页
通过可选的 `discoveryPending` / `discoveryWarnings` 投影进度和非致命错误,已有原生会话始终可读。
服务主机合并同一批工作区打开请求的来源目录扫描,Codex 读取器按文件大小和修改时间复用已解析的
有界头部,不重复读取未变化文件。启动仅将会话标题、来源和归属写入集中会话目录的
有界头部,不重复读取未变化文件。同批来源目录按不同路径计算并共享工作区归属;工作区登记根变化或
下一次目录扫描时重新计算,保留最近本地工作区及同根登记顺序的匹配规则。启动仅将会话标题、来源和归属写入集中会话目录的
`.external-session-index.json`;该私有索引由现有持久化入口合并到元数据分页,旧主机忽略此文件,
既有原生 Session 格式不变。改名、归档和删除只更新索引;删除保留抑制记录,重新扫描不复活条目。
会话元数据分页通过可选 `query` 声明工作区 ID、创建/最近活动排序、来源及已观察到的活动时间,
主机完成归属筛选、父子分组和来源筛选后返回分页与精确计数,并以 `catalogQuery` 回显已应用条件。
Web UI 默认仅读取可见页,展开时续读,复用启动首屏;相同时间的边界条目一起返回,保留客户端的本地化排序。
每轮查询固定所有已观察到的活动时间,包括仍与本地元数据相同的时间,避免主机后续完成记录改变选页结果。
本轮新加载条目不触发重复查询;主机目录修订变化时,重新固定这些条目的已观察时间,再从首段续建分页视图。
名称/状态排序、未读/关注及其他组合筛选继续使用完整目录投影。没有查询回显的旧主机、无法确定工作区 ID 的
旧记录也保留原分页遍历,不能把共享存储根计数当成工作区计数。Desktop 与 CLI Peer 使用同一查询实现;
切换设备、工作区、来源或发现刷新会隔离读取。主机按存储目录、查询和原生/外部索引文件修订复用合并、排序后的
有界内存快照;命中后只校验索引修订和本页原生文件存在性,不再解析、合并整个目录。索引的修改、替换或删除
会使缓存失效,包括其他进程写入;游标绑定查询及修订,版本变化通过 `catalogReset` 返回新目录首段,前端重置累计数量。
缓存淘汰不改变持久化格式或删除会话;过大目录仍按页返回,但不常驻缓存。分页不加载会话正文。
源会话的移除动作显示为“从 OpenBitFun 移除”,不修改外部原始记录或已有原生分支。生态兼容页
列出当前主机上已移除的来源,用户主动重新添加时按工作区 ID 和来源身份校验,恢复同一个会话 ID、
自定义名称和缓存;恢复操作幂等,迟到的重试不会撤销之后的归档。旧重复项恢复为隐藏别名。
Expand All @@ -114,6 +126,13 @@ Zcode 会话读取不意味着其 ACP 运行时、设置、Skill、MCP 或 Hook
来源追加、回退或重写生成新版本。缓存缺失时来源失败明确报错;已有缓存则保留并标记来源不可用,
不发布空历史、不删除条目。元数据及工作区正文搜索不读取来源正文;正文搜索排除跟随视图并返回说明。
Web UI 每次打开来源会话时读取一次来源;停留、输入、窗口恢复可见及查看原生分支都不触发刷新。
正文版本相同时复用已经校验的快照,刷新结果直接交给本次读取;索引条目未变化时不写盘或通知目录变更。
主机按存储目录、会话 ID 和内容版本共享不可变正文、轮次目录与定位索引,内存保留按估算驻留字节限制为 64 MiB,
最多 128 个版本。命中后的尾部、单轮和窗口读取只复制请求轮次,不重读或重建整个快照;同版本并发冷读合并。
改名、归档及来源状态每次从当前索引叠加,已移除的视图或旧主机写出的独立原生副本仍优先遵循现有生命周期规则。
快照文件修改、替换或消失会使内存项失效;缺失或损坏明确报错。首次冷读仍解析完整版本文件,超出保留预算的版本
仍可读取但不常驻缓存,每次打开也仍须验证来源正文。内存淘汰不删除磁盘版本,不改变 v1 快照或窗口/分叉请求格式。
旧原生副本仍执行本地编辑校验,来源不可用、标题变化以及并发改名、归档和移除仍按既有规则处理。
首次发送使用现有分叉入口复制已显示版本并切换到原生分支,之后使用主机当前模型与权限配置。
分支以 `externalBranchOf` 保留来源及版本,来源仍可独立更新,后续内容不自动混入分支。
编辑或回退来源历史须先显式分叉;共享持久化及运行时入口拒绝直接修改跟随视图。
Expand All @@ -122,7 +141,7 @@ Web UI 每次打开来源会话时读取一次来源;停留、输入、窗口
通过核对后记录本地历史摘要,后续来源改写不会误判为本地编辑;旧主机再次修改本地历史时重新核对。
跟随条目保留旧 `externalHistoryPending` 标记,旧版主机仍能物化副本;手动导入的原生副本始终独立。
来源输入区采用当前主机默认权限,用户选择先存于本地输入状态,创建分支后才写入分支,不恢复来源运行时。私有索引新字段可选,
不改变现有原生会话 invoke 请求形状;不理解跟随视图的客户端直接提交时收到明确的只读/需要分叉错误。
保留现有原生会话 invoke 的旧请求兼容性;不理解跟随视图的客户端直接提交时收到明确的只读/需要分叉错误。
改名、归档、删除意图独立于缓存更新,既有完整副本及历史版本不自动清理。
来源标题由生态 adapter 读取:Codex 优先使用 `state_*.sqlite` 的 `threads.name`,随后是
`session_index.jsonl` 最新名称、数据库 `title`、rollout 头部名称及有效用户输入摘要;旧版
Expand Down
38 changes: 38 additions & 0 deletions scripts/check-core-boundaries.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,44 @@ const MODULES = [

const TEST_ROOT = join('C:', 'repo');

test('session metadata paging delegates query parameters to the services-core store', async () => {
const managerRule = requiredContentRules.find(
rule => rule.path === 'src/crates/assembly/core/src/agentic/persistence/manager.rs',
);
const pageRule = managerRule.patterns.find(
pattern => pattern.message === 'missing metadata page delegation to services-core store',
);
const source = await readFile(new URL(`../${managerRule.path}`, import.meta.url), 'utf8');
const call = '.list_metadata_page_with_query(cursor, limit, query)';
assert.ok(source.includes(call));
assert.ok(pageRule.regex.test(source));
for (const replacement of [
'.list_metadata_page(cursor, limit)',
'.list_metadata_page_with_query(cursor, limit, None)',
'.list_metadata_page_with_query(None, limit, query)',
'.list_metadata_page_with_query(cursor, 1, query)',
]) {
assert.ok(!pageRule.regex.test(source.replace(call, replacement)), replacement);
}
assert.ok(!pageRule.regex.test(source.replaceAll(
'.session_metadata_store(workspace_path)',
'.local_metadata_store(workspace_path)',
)));

const storeRule = requiredContentRules.find(
rule => rule.path === 'src/crates/services/services-core/src/session/metadata_store.rs',
);
const storeSource = await readFile(new URL(`../${storeRule.path}`, import.meta.url), 'utf8');
const acceptsStore = text => storeRule.patterns.every(({ regex }) => regex.test(text));
assert.ok(acceptsStore(storeSource));
for (const entrypoint of ['list_metadata_page', 'list_metadata_page_with_query']) {
assert.ok(!acceptsStore(storeSource.replace(
`pub async fn ${entrypoint}(`,
'pub async fn removed_entrypoint(',
)), `must keep ${entrypoint} in the services-core store`);
}
});

test('workspace instruction owner requires family priority and non-empty fallback regressions', async () => {
const rule = requiredContentRules.find(
rule => rule.path === 'src/crates/services/services-core/tests/declarative_workspace_instruction_contracts.rs',
Expand Down
6 changes: 5 additions & 1 deletion scripts/core-boundaries/rules/source/required-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3009,6 +3009,10 @@ export const requiredContentRules = [
regex: /\bpub async fn list_metadata_page\b/,
message: 'missing session metadata page owner entrypoint',
},
{
regex: /\bpub async fn list_metadata_page_with_query\b/,
message: 'missing query-aware session metadata page owner entrypoint',
},
{
regex: /\bpub async fn list_metadata_including_internal\b/,
message: 'missing internal session metadata list owner entrypoint',
Expand Down Expand Up @@ -3226,7 +3230,7 @@ export const requiredContentRules = [
message: 'missing metadata list delegation to services-core store',
},
{
regex: /\.list_metadata_page\(cursor,\s*limit\)/,
regex: /\.session_metadata_store\(workspace_path\)\s*\.list_metadata_page_with_query\(cursor,\s*limit,\s*query\)/,
message: 'missing metadata page delegation to services-core store',
},
{
Expand Down
3 changes: 2 additions & 1 deletion scripts/core-boundaries/self-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3407,6 +3407,7 @@ export function runManifestParserSelfTest({
'SessionStorageLayout',
'list_metadata',
'list_metadata_page',
'list_metadata_page_with_query',
'list_metadata_including_internal',
'rebuild_index',
'save_metadata',
Expand Down Expand Up @@ -3438,7 +3439,7 @@ export function runManifestParserSelfTest({
'SessionMetadataStore',
'session_metadata_store',
'list_metadata',
'list_metadata_page',
'list_metadata_page_with_query',
'list_metadata_including_internal',
'save_metadata',
'load_metadata',
Expand Down
54 changes: 53 additions & 1 deletion src/apps/cli/src/peer_host/commands/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,20 +317,39 @@ pub(crate) async fn list_persisted_sessions_page(
.map(|value| serde_json::from_value::<Vec<String>>(value.clone()))
.transpose()
.map_err(|error| format!("Invalid session activity ids: {error}"))?;
let query = session_catalog_query(request)?;
let page = state
.compatibility
.list_persisted_sessions_page_with_activity(
.list_persisted_sessions_page_with_activity_and_query(
&state.agent_runtime,
&workspace_path,
cursor.as_deref(),
limit,
session_ids.as_deref(),
query.as_ref(),
)
.await
.map_err(|e| format!("Failed to list persisted session page: {e}"))?;
serde_json::to_value(page).map_err(|e| format!("serialize session page: {e}"))
}

fn session_catalog_query(
request: &Value,
) -> Result<Option<openbitfun_services_core::session::page::SessionCatalogQuery>, String> {
let query = request
.get("query")
.filter(|value| !value.is_null())
.map(|value| {
serde_json::from_value::<openbitfun_services_core::session::page::SessionCatalogQuery>(
value.clone(),
)
})
.transpose()
.map_err(|error| format!("Invalid session catalog query: {error}"))?;
let workspace_id = optional_string(request, "workspaceId");
Ok(query.filter(|query| Some(query.workspace_id.as_str()) == workspace_id.as_deref()))
}

pub(crate) async fn list_persisted_sessions_count(
state: &PeerHostState,
args: &Value,
Expand Down Expand Up @@ -999,6 +1018,39 @@ mod tests {
use openbitfun_core::util::errors::OpenBitFunError;
use openbitfun_events::AgenticEvent;

#[test]
fn session_catalog_query_keeps_legacy_peers_and_workspace_identity_compatible() {
let query =
serde_json::json!({"workspaceId": "main", "ordering": "updated", "source": "codex"});
for field in ["workspace_id", "workspaceId"] {
let mut request = serde_json::json!({"query": query});
request[field] = serde_json::json!("main");
assert_eq!(
super::session_catalog_query(&request)
.unwrap()
.unwrap()
.workspace_id,
"main"
);
request[field] = serde_json::json!("another-workspace");
assert!(super::session_catalog_query(&request).unwrap().is_none());
}
assert!(
super::session_catalog_query(&serde_json::json!({"workspace_path": "/old/peer"}))
.unwrap()
.is_none()
);
assert!(super::session_catalog_query(
&serde_json::json!({"workspace_path": "/old/peer", "query": query})
)
.unwrap()
.is_none());
assert!(
super::session_catalog_query(&serde_json::json!({"query": {"source": "codex"}}))
.is_err()
);
}

#[test]
fn peer_writer_conflicts_keep_the_stable_transport_code() {
let core_error = peer_core_session_error(
Expand Down
3 changes: 3 additions & 0 deletions src/apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ For whole-session copy routing, run
`cargo test --locked -p openbitfun-desktop --lib whole_session_copy_uses_the_latest_persisted_turn_fork`.
After command registration changes, also run the `remote_workspace_policy` filter.

For optional workspace catalog query payload compatibility, run
`cargo test --locked -p openbitfun-desktop --lib api::session_api::catalog_request_tests`.

For model selector request compatibility and configuration effect routing:

```bash
Expand Down
24 changes: 24 additions & 0 deletions src/apps/desktop/src/api/session_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pub struct ListPersistedSessionsPageRequest {
#[serde(default, alias = "sessionIds")]
pub session_ids: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<openbitfun_services_core::session::page::SessionCatalogQuery>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_connection_id: Option<String>,
Expand Down Expand Up @@ -458,6 +460,7 @@ pub async fn list_persisted_sessions_page(
request.cursor.as_deref(),
request.limit,
request.session_ids.as_deref(),
request.query.as_ref(),
)
.await
.map_err(|error| {
Expand Down Expand Up @@ -910,3 +913,24 @@ pub async fn delete_all_archived_sessions(

Ok(deleted_count)
}

#[cfg(test)]
mod catalog_request_tests {
use super::ListPersistedSessionsPageRequest;

#[test]
fn session_catalog_request_preserves_legacy_payload_and_optional_query() {
let legacy = serde_json::json!({"workspace_id": "main", "workspace_path": "", "limit": 5, "session_ids": null});
let request: ListPersistedSessionsPageRequest =
serde_json::from_value(legacy.clone()).unwrap();
assert!(request.query.is_none());
assert_eq!(serde_json::to_value(request).unwrap(), legacy);
let mut current = legacy;
current["query"] =
serde_json::json!({"workspaceId": "main", "ordering": "updated", "source": "codex"});
let request: ListPersistedSessionsPageRequest =
serde_json::from_value(current.clone()).unwrap();
assert_eq!(request.query.as_ref().unwrap().workspace_id, "main");
assert_eq!(serde_json::to_value(request).unwrap(), current);
}
}
4 changes: 3 additions & 1 deletion src/apps/desktop/src/runtime/session_application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,17 +392,19 @@ impl DesktopSessionApplication {
cursor: Option<&str>,
limit: usize,
session_ids: Option<&[String]>,
query: Option<&openbitfun_services_core::session::page::SessionCatalogQuery>,
) -> DesktopSessionApplicationResult<SessionMetadataPage> {
let scope = self.resolved_scope(request).await?;
let storage_path = self.storage_path(&scope);
let mut page = self
.compatibility
.list_persisted_sessions_page_with_activity(
.list_persisted_sessions_page_with_activity_and_query(
&self.agent_runtime,
&storage_path,
cursor,
limit,
session_ids,
query.filter(|query| query.workspace_id == scope.workspace_id),
)
.await
.map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?;
Expand Down
9 changes: 9 additions & 0 deletions src/crates/assembly/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,17 @@ they do not exercise live SSH, relay, peer, or dispatch connections.

For disk-backed history paging and legacy sessions without a catalog:
`cargo test --locked -p openbitfun-core --no-default-features --features remote-connect,git --lib history_page_`.

Also run the `staged_revert_catalog_projection` and `load_relay_session_turns_`
filters for the same target when changing visibility. Paging must not parse
unrelated turn bodies or rewrite history. To compare real-file first-page work
against full materialization locally, use the same target with
`history_page_benchmark -- --ignored --nocapture`; it checks content equivalence
and reports timings without asserting a machine-dependent latency in CI.

For workspace metadata catalog paging, cache reuse and cross-process invalidation:
`cargo test --locked -p openbitfun-core --no-default-features --features external-sources,git,product-search --lib -- catalog_snapshot_ catalog_query_pages_`.

For host-stream history reads and abandoned execution after a runtime restart:
`cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib load_relay_session_turns_`.
The observer must preserve terminal history and another process's writer lease;
Expand Down Expand Up @@ -390,6 +394,11 @@ Optional read-only Zcode-to-temporary-native-store verification uses the same
target with `inspect_installed_zcode_native_import -- --ignored --nocapture`.
It reads the installed source and writes only inside a temporary test directory.

For immutable external-history cache/window reads, byte-budget eviction, v1
snapshot compatibility and viewed-version isolation, use the same target with
`history_cache_`. The fixtures count real-file snapshot reads, derived catalog
builds and copied turns; they do not measure native WebView or remote latency.

The same target also supports `inspect_installed_codex_history_repair -- --ignored --nocapture`.
Set `OPENBITFUN_TEST_NATIVE_SESSIONS_ROOT` and `OPENBITFUN_TEST_NATIVE_SESSION_ID`
to an existing answerless Codex import. It reads the installed native copy and
Expand Down
Loading
Loading