feat: 收口 tree-first graph 主链与前端测试修复
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id,
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
load_projection_snapshot, ProjectionSnapshotSpec,
|
||||
};
|
||||
use axum::extract::Query;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::{KernelNodeType, KernelProjectionKind};
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
@@ -59,41 +59,28 @@ pub async fn next_sidebar(
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
|
||||
let dataset = fetch_query_data_via_convex(
|
||||
let snapshot = load_projection_snapshot(
|
||||
state.config(),
|
||||
&context,
|
||||
Some(&effective_workspace_id),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "sidebar.dataset.list".into(),
|
||||
payload: json!({
|
||||
"workspaceId": effective_workspace_id,
|
||||
}),
|
||||
&ProjectionSnapshotSpec {
|
||||
workspace_id: &effective_workspace_id,
|
||||
root_node_id: None,
|
||||
depth: None,
|
||||
projection: KernelProjectionKind::SidebarTree,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let projection = execute_runtime_query_against_data(
|
||||
&context,
|
||||
Some(&effective_workspace_id),
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.project_view".into(),
|
||||
payload: json!({
|
||||
"projection": KernelProjectionKind::SidebarTree,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"includeEdges": true,
|
||||
"includeContent": false,
|
||||
"nodeTypes": [KernelNodeType::Page],
|
||||
}),
|
||||
},
|
||||
dataset.clone(),
|
||||
)?;
|
||||
|
||||
let mut dataset_object = dataset.as_object().cloned().ok_or_else(|| {
|
||||
let mut dataset_object = snapshot.dataset.as_object().cloned().ok_or_else(|| {
|
||||
WebError::bad_gateway_code("convex_bad_response", "sidebar.dataset.list 返回值不是对象")
|
||||
.with_context(&context)
|
||||
.with_header("x-error-phase", "compat_sidebar_shape")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
dataset_object.insert("kernel_sidebar_projection".into(), projection);
|
||||
dataset_object.insert(
|
||||
"kernel_sidebar_projection".into(),
|
||||
snapshot.projection,
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::{KernelGraphDirection, KernelNodeType, KernelProjectionKind};
|
||||
use core_protocol::{KernelGraphDirection, KernelProjectionKind};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -43,38 +47,6 @@ pub struct KernelGraphQuery {
|
||||
pub max_depth: Option<u32>,
|
||||
}
|
||||
|
||||
fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire {
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "sidebar.dataset.list".into(),
|
||||
payload: json!({
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_sidebar_dataset(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
fetch_query_data_via_convex(
|
||||
config,
|
||||
context,
|
||||
Some(workspace_id),
|
||||
sidebar_dataset_query(workspace_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn execute_kernel_query(
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
dataset: Value,
|
||||
) -> Result<Value, WebError> {
|
||||
execute_runtime_query_against_data(context, Some(workspace_id), query, dataset)
|
||||
}
|
||||
|
||||
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
@@ -95,26 +67,19 @@ pub async fn project_sidebar(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
|
||||
let result = execute_kernel_query(
|
||||
let snapshot = load_projection_snapshot(
|
||||
state.config(),
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.project_view".into(),
|
||||
payload: json!({
|
||||
"projection": KernelProjectionKind::SidebarTree,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"rootNodeId": query.root_node_id,
|
||||
"depth": query.depth,
|
||||
"includeEdges": true,
|
||||
"includeContent": false,
|
||||
"nodeTypes": [KernelNodeType::Page],
|
||||
}),
|
||||
&ProjectionSnapshotSpec {
|
||||
workspace_id: &effective_workspace_id,
|
||||
root_node_id: query.root_node_id.as_deref(),
|
||||
depth: query.depth,
|
||||
projection: KernelProjectionKind::SidebarTree,
|
||||
},
|
||||
dataset,
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ok_response(&context, result))
|
||||
Ok(ok_response(&context, snapshot.projection))
|
||||
}
|
||||
|
||||
pub async fn subtree(
|
||||
@@ -129,16 +94,7 @@ pub async fn subtree(
|
||||
let result = execute_kernel_query(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.subtree.get".into(),
|
||||
payload: json!({
|
||||
"workspaceId": effective_workspace_id,
|
||||
"rootNodeId": query.root_node_id,
|
||||
"depth": query.depth,
|
||||
"includeEdges": true,
|
||||
"nodeTypes": [KernelNodeType::Page],
|
||||
}),
|
||||
},
|
||||
subtree_query(&effective_workspace_id, &query.root_node_id, query.depth),
|
||||
dataset,
|
||||
)?;
|
||||
Ok(ok_response(&context, result))
|
||||
|
||||
@@ -5,7 +5,9 @@ mod health;
|
||||
mod hermes;
|
||||
mod kernel;
|
||||
mod query_support;
|
||||
mod snapshot_support;
|
||||
mod sse;
|
||||
mod stream_support;
|
||||
mod tree;
|
||||
mod ws;
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, fetch_query_data_via_convex,
|
||||
};
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::{KernelNodeType, KernelProjectionKind};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectionSnapshotSpec<'a> {
|
||||
pub workspace_id: &'a str,
|
||||
pub root_node_id: Option<&'a str>,
|
||||
pub depth: Option<u32>,
|
||||
pub projection: KernelProjectionKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectionSnapshot {
|
||||
pub dataset: Value,
|
||||
pub projection: Value,
|
||||
}
|
||||
|
||||
pub fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire {
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "sidebar.dataset.list".into(),
|
||||
payload: json!({
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_sidebar_dataset(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
fetch_query_data_via_convex(
|
||||
config,
|
||||
context,
|
||||
Some(workspace_id),
|
||||
sidebar_dataset_query(workspace_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn projection_query(spec: &ProjectionSnapshotSpec<'_>) -> RuntimeQueryEnvelopeWire {
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.project_view".into(),
|
||||
payload: json!({
|
||||
"projection": spec.projection,
|
||||
"workspaceId": spec.workspace_id,
|
||||
"rootNodeId": spec.root_node_id,
|
||||
"depth": spec.depth,
|
||||
"includeEdges": true,
|
||||
"includeContent": false,
|
||||
"nodeTypes": [KernelNodeType::Page],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subtree_query(
|
||||
workspace_id: &str,
|
||||
root_node_id: &str,
|
||||
depth: Option<u32>,
|
||||
) -> RuntimeQueryEnvelopeWire {
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.subtree.get".into(),
|
||||
payload: json!({
|
||||
"workspaceId": workspace_id,
|
||||
"rootNodeId": root_node_id,
|
||||
"depth": depth,
|
||||
"includeEdges": true,
|
||||
"nodeTypes": [KernelNodeType::Page],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_kernel_query(
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
dataset: Value,
|
||||
) -> Result<Value, WebError> {
|
||||
execute_runtime_query_against_data(context, Some(workspace_id), query, dataset)
|
||||
}
|
||||
|
||||
pub async fn load_projection_snapshot(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
spec: &ProjectionSnapshotSpec<'_>,
|
||||
) -> Result<ProjectionSnapshot, WebError> {
|
||||
let dataset = load_sidebar_dataset(config, context, spec.workspace_id).await?;
|
||||
let projection = execute_kernel_query(
|
||||
context,
|
||||
spec.workspace_id,
|
||||
projection_query(spec),
|
||||
dataset.clone(),
|
||||
)?;
|
||||
|
||||
Ok(ProjectionSnapshot {
|
||||
dataset,
|
||||
projection,
|
||||
})
|
||||
}
|
||||
@@ -1,33 +1,80 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use axum::extract::Extension;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures_util::stream;
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn events(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>> {
|
||||
let payload = json!({
|
||||
"kind": "sse_placeholder",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"workspaceId": context.workspace.workspace_id,
|
||||
"notes": [
|
||||
"当前为 task-062 最小骨架,后续在此对齐统一流式输出协议。",
|
||||
"此路由预留给 Hermes token/tool/client event 回流。"
|
||||
]
|
||||
});
|
||||
Query(query): Query<StreamSnapshotQuery>,
|
||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
|
||||
let payload = load_stream_snapshot(state.config(), &context, &query).await?;
|
||||
let event = snapshot_event(&payload);
|
||||
|
||||
let event = Event::default()
|
||||
.event("ready")
|
||||
.json_data(payload)
|
||||
.expect("SSE 占位事件必须可序列化");
|
||||
|
||||
Sse::new(stream::iter(vec![Ok(event)])).keep_alive(
|
||||
Ok(Sse::new(stream::iter(vec![Ok(event)])).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text("keepalive"),
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn snapshot_event(payload: &Value) -> Event {
|
||||
Event::default()
|
||||
.event("snapshot")
|
||||
.json_data(payload)
|
||||
.expect("SSE snapshot 事件必须可序列化")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"trashed_documents":[],"media_assets":[],"trashed_media_assets":[],"mindmap_assets":[],"trashed_mindmap_assets":[],"table_assets":[],"trashed_table_assets":[],"mindmap_docs":[],"mindmap_asset_children":{}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":"cursor_demo","has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_route_returns_workspace_snapshot_event() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/stream/events?workspaceId=ws_demo")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
|
||||
assert!(text.contains("\"scope\":\"workspace\""));
|
||||
assert!(text.contains("\"workspaceId\":\"ws_demo\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
};
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamSnapshotQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
pub root_node_id: Option<String>,
|
||||
pub depth: Option<u32>,
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StreamSnapshotScope {
|
||||
Workspace,
|
||||
Subtree,
|
||||
}
|
||||
|
||||
impl StreamSnapshotScope {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "workspace",
|
||||
Self::Subtree => "subtree",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope {
|
||||
if query
|
||||
.root_node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
StreamSnapshotScope::Subtree
|
||||
} else {
|
||||
StreamSnapshotScope::Workspace
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_overview_query(
|
||||
workspace_id: &str,
|
||||
query: &StreamSnapshotQuery,
|
||||
) -> RuntimeQueryEnvelopeWire {
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "bridge.workspace.overview".into(),
|
||||
payload: json!({
|
||||
"workspaceId": workspace_id,
|
||||
"cursor": query.cursor,
|
||||
"limit": query.limit.unwrap_or(20),
|
||||
"commandStatus": Value::Null,
|
||||
"eventStatus": Value::Null,
|
||||
"targetPageId": Value::Null,
|
||||
"targetBlockId": Value::Null,
|
||||
"aggregateType": Value::Null,
|
||||
"aggregateId": query.root_node_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_stream_snapshot(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
query: &StreamSnapshotQuery,
|
||||
) -> Result<Value, WebError> {
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let scope = resolve_stream_scope(query);
|
||||
|
||||
let snapshot = match scope {
|
||||
StreamSnapshotScope::Workspace => {
|
||||
let loaded = load_projection_snapshot(
|
||||
config,
|
||||
context,
|
||||
&ProjectionSnapshotSpec {
|
||||
workspace_id: &effective_workspace_id,
|
||||
root_node_id: None,
|
||||
depth: query.depth,
|
||||
projection: KernelProjectionKind::SidebarTree,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
json!({
|
||||
"dataset": loaded.dataset,
|
||||
"tree": loaded.projection,
|
||||
})
|
||||
}
|
||||
StreamSnapshotScope::Subtree => {
|
||||
let root_node_id = query
|
||||
.root_node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.expect("subtree scope 已确保 rootNodeId 存在");
|
||||
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
|
||||
let tree = execute_kernel_query(
|
||||
context,
|
||||
&effective_workspace_id,
|
||||
subtree_query(&effective_workspace_id, root_node_id, query.depth),
|
||||
dataset.clone(),
|
||||
)?;
|
||||
|
||||
json!({
|
||||
"dataset": dataset,
|
||||
"tree": tree,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let overview = execute_runtime_query_via_convex(
|
||||
config,
|
||||
context,
|
||||
Some(&effective_workspace_id),
|
||||
workspace_overview_query(&effective_workspace_id, query),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(json!({
|
||||
"kind": "snapshot",
|
||||
"scope": scope.as_str(),
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"rootNodeId": query.root_node_id,
|
||||
"depth": query.depth,
|
||||
"snapshot": snapshot,
|
||||
"overview": overview,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_scope, StreamSnapshotQuery, StreamSnapshotScope};
|
||||
|
||||
#[test]
|
||||
fn stream_scope_defaults_to_workspace() {
|
||||
assert_eq!(
|
||||
resolve_stream_scope(&StreamSnapshotQuery::default()),
|
||||
StreamSnapshotScope::Workspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_scope_switches_to_subtree_when_root_exists() {
|
||||
assert_eq!(
|
||||
resolve_stream_scope(&StreamSnapshotQuery {
|
||||
root_node_id: Some("page_root".into()),
|
||||
..StreamSnapshotQuery::default()
|
||||
}),
|
||||
StreamSnapshotScope::Subtree
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,16 @@ use crate::routes::command_support::{
|
||||
read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
load_projection_snapshot, ProjectionSnapshotSpec,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeCommandEnvelopeWire;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
@@ -73,39 +75,6 @@ pub enum TreeCommandRequest {
|
||||
},
|
||||
}
|
||||
|
||||
fn tree_projection_query(
|
||||
workspace_id: &str,
|
||||
root_node_id: Option<&str>,
|
||||
depth: Option<u32>,
|
||||
mode: &str,
|
||||
) -> RuntimeQueryEnvelopeWire {
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "kernel.project_view".into(),
|
||||
payload: json!({
|
||||
"projection": if mode == "filetree" {
|
||||
KernelProjectionKind::FileTree
|
||||
} else {
|
||||
KernelProjectionKind::PageTree
|
||||
},
|
||||
"workspaceId": workspace_id,
|
||||
"rootNodeId": root_node_id,
|
||||
"depth": depth,
|
||||
"includeEdges": true,
|
||||
"includeContent": false,
|
||||
"nodeTypes": ["page"],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire {
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "sidebar.dataset.list".into(),
|
||||
payload: json!({
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
@@ -1918,24 +1887,21 @@ pub async fn tree_shell(
|
||||
let mode = normalize_tree_mode(query.mode.as_deref());
|
||||
let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false);
|
||||
let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref());
|
||||
let dataset = fetch_query_data_via_convex(
|
||||
let snapshot = load_projection_snapshot(
|
||||
state.config(),
|
||||
&effective_context,
|
||||
Some(&effective_workspace_id),
|
||||
sidebar_dataset_query(&effective_workspace_id),
|
||||
&ProjectionSnapshotSpec {
|
||||
workspace_id: &effective_workspace_id,
|
||||
root_node_id: query.root_node_id.as_deref(),
|
||||
depth: query.depth,
|
||||
projection: if mode == "filetree" {
|
||||
KernelProjectionKind::FileTree
|
||||
} else {
|
||||
KernelProjectionKind::PageTree
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let projection = execute_runtime_query_against_data(
|
||||
&effective_context,
|
||||
Some(&effective_workspace_id),
|
||||
tree_projection_query(
|
||||
&effective_workspace_id,
|
||||
query.root_node_id.as_deref(),
|
||||
query.depth,
|
||||
mode,
|
||||
),
|
||||
dataset.clone(),
|
||||
)?;
|
||||
let html = build_tree_shell_html(
|
||||
&effective_workspace_id,
|
||||
query.root_node_id.as_deref(),
|
||||
@@ -1943,11 +1909,11 @@ pub async fn tree_shell(
|
||||
&normalize_channel(query.channel),
|
||||
query.host.as_deref(),
|
||||
&effective_context,
|
||||
&projection,
|
||||
&snapshot.projection,
|
||||
mode,
|
||||
allow_root_pick,
|
||||
&exclude_ids,
|
||||
&dataset,
|
||||
&snapshot.dataset,
|
||||
);
|
||||
let mut response = Html(html).into_response();
|
||||
response
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::Extension;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::response::Response;
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn socket(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, context))
|
||||
Query(query): Query<StreamSnapshotQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let snapshot = load_stream_snapshot(state.config(), &context, &query).await?;
|
||||
let state = state.clone();
|
||||
let context = context.clone();
|
||||
let query = query.clone();
|
||||
|
||||
Ok(ws.on_upgrade(move |socket| handle_socket(socket, state, context, query, snapshot)))
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, context: RequestContext) {
|
||||
let payload = json!({
|
||||
"kind": "ws_placeholder",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"workspaceId": context.workspace.workspace_id,
|
||||
"notes": [
|
||||
"当前为 task-062 最小骨架,后续可承接协作推送和运行态事件。"
|
||||
]
|
||||
});
|
||||
|
||||
async fn handle_socket(
|
||||
mut socket: WebSocket,
|
||||
state: AppState,
|
||||
context: RequestContext,
|
||||
query: StreamSnapshotQuery,
|
||||
payload: Value,
|
||||
) {
|
||||
let _ = socket
|
||||
.send(Message::Text(payload.to_string().into()))
|
||||
.send(serialize_snapshot_message(&payload))
|
||||
.await;
|
||||
|
||||
while let Some(message) = socket.next().await {
|
||||
@@ -34,17 +40,41 @@ async fn handle_socket(mut socket: WebSocket, context: RequestContext) {
|
||||
|
||||
match message {
|
||||
Message::Text(text) => {
|
||||
let echo = json!({
|
||||
"kind": "ws_echo",
|
||||
"traceId": context.trace.trace_id,
|
||||
"text": text.to_string(),
|
||||
});
|
||||
if socket
|
||||
.send(Message::Text(echo.to_string().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
if is_resync_request(&text) {
|
||||
match load_stream_snapshot(state.config(), &context, &query).await {
|
||||
Ok(snapshot) => {
|
||||
if socket.send(serialize_resync_message(&snapshot)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let payload = json!({
|
||||
"kind": "error",
|
||||
"code": "snapshot_reload_failed",
|
||||
"message": format!("{error:?}"),
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
});
|
||||
if socket
|
||||
.send(Message::Text(payload.to_string().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let ack = json!({
|
||||
"kind": "ack",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"accepted": false,
|
||||
"reason": "unsupported_message",
|
||||
});
|
||||
if socket.send(Message::Text(ack.to_string().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
@@ -52,3 +82,62 @@ async fn handle_socket(mut socket: WebSocket, context: RequestContext) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_snapshot_message(payload: &Value) -> Message {
|
||||
Message::Text(payload.to_string().into())
|
||||
}
|
||||
|
||||
fn serialize_resync_message(payload: &Value) -> Message {
|
||||
Message::Text(
|
||||
json!({
|
||||
"kind": "resync",
|
||||
"snapshot": payload,
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_resync_request(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.eq_ignore_ascii_case("resync") {
|
||||
return true;
|
||||
}
|
||||
|
||||
serde_json::from_str::<Value>(trimmed)
|
||||
.ok()
|
||||
.and_then(|value| value.get("type").and_then(Value::as_str).map(str::to_owned))
|
||||
.map(|value| value.eq_ignore_ascii_case("resync"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_resync_request, serialize_resync_message, serialize_snapshot_message};
|
||||
use axum::extract::ws::Message;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn ws_resync_detection_accepts_plain_text_and_json() {
|
||||
assert!(is_resync_request("resync"));
|
||||
assert!(is_resync_request(r#"{"type":"resync"}"#));
|
||||
assert!(!is_resync_request("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_snapshot_serializers_emit_text_frames() {
|
||||
let snapshot = json!({
|
||||
"kind": "snapshot",
|
||||
"scope": "workspace",
|
||||
});
|
||||
let Message::Text(snapshot_text) = serialize_snapshot_message(&snapshot) else {
|
||||
panic!("snapshot message 应该是文本帧");
|
||||
};
|
||||
assert!(snapshot_text.contains("\"kind\":\"snapshot\""));
|
||||
|
||||
let Message::Text(resync_text) = serialize_resync_message(&snapshot) else {
|
||||
panic!("resync message 应该是文本帧");
|
||||
};
|
||||
assert!(resync_text.contains("\"kind\":\"resync\""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user