feat: 收口 tree-first graph 主链与前端测试修复

This commit is contained in:
lix-2026
2026-04-18 05:43:49 +08:00
parent d8de820d93
commit d3876e56eb
33 changed files with 2421 additions and 345 deletions
+122
View File
@@ -488,6 +488,19 @@ struct DocumentSaveCommandPayload {
conflict_detection_key: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentEmbedCommandPayload {
document_id: String,
workspace_id: Option<String>,
revision: Option<u64>,
content: Value,
conflict_detection_key: Option<String>,
source_document_id: String,
target_document_id: String,
anchor_block_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelCreateNodeCommandPayload {
@@ -4149,6 +4162,53 @@ fn execute_command(
}),
}))
}
"documents.embed" => {
let payload: DocumentEmbedCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope {
name: "documents.embed".into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
source: to_source_payload(&command_wire.source),
target: to_target_ref(command_wire.target.as_ref()),
payload: core_protocol::SavePageContent {
page_id: payload.document_id.clone(),
workspace_id: payload.workspace_id.clone(),
revision: payload.revision,
content_json: serde_json::to_string(&payload.content).map_err(|error| {
BridgeError::validation(format!(
"documents.embed content 序列化失败: {error}"
))
})?,
conflict_detection_key: payload.conflict_detection_key.clone(),
},
reason: command_wire.reason,
refs: command_wire.refs,
dry_run: command_wire.dry_run,
validate_only: command_wire.validate_only,
};
let request = build_write_request(&context, &command)?;
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
command_name: command.name,
command_id: command.command_id,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
idempotency_key: request.idempotency_key,
payload_json: request.payload_json,
args_json: json!({
"id": payload.document_id,
"content": payload.content,
"expectedRevision": payload.revision,
"conflictDetectionKey": payload.conflict_detection_key,
"sourceDocumentId": payload.source_document_id,
"targetDocumentId": payload.target_document_id,
"anchorBlockId": payload.anchor_block_id,
}),
}))
}
"mindmaps.put" => {
let payload: MindmapPutCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope {
@@ -5509,6 +5569,68 @@ mod tests {
}
}
#[test]
fn documents_embed_command_plan_maps_to_documents_update_content() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.embed".into(),
command_id: "cmd_doc_embed_1".into(),
idempotency_key: Some("idem_doc_embed".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_2".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_2",
"workspaceId": "ws_1",
"revision": 5,
"content": [{ "id": "block_1", "type": "pageReference" }],
"conflictDetectionKey": "conflict_5",
"sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1",
}),
reason: Some("嵌入页面".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("command plan should build");
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.command_name, "documents.embed");
assert_eq!(
plan.args_json,
json!({
"id": "doc_2",
"content": [{ "id": "block_1", "type": "pageReference" }],
"expectedRevision": 5,
"conflictDetectionKey": "conflict_5",
"sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1",
})
);
}
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
}
}
#[test]
fn mindmap_get_tool_plan_uses_mindmaps_get_query() {
let plan = execute_runtime_input(RuntimeInput::Tool {
+15 -28
View File
@@ -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,
+18 -62
View File
@@ -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))
+2
View File
@@ -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,
})
}
+67 -20
View File
@@ -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
);
}
}
+17 -51
View File
@@ -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('&', "&amp;")
@@ -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
+116 -27
View File
@@ -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\""));
}
}
@@ -237,6 +237,46 @@ mod tests {
assert!(request.payload_json.contains("\"name\":\"documents.save\""));
}
#[test]
fn document_embed_command_maps_to_documents_update_content() {
let command = CommandEnvelope {
name: "documents.embed".into(),
command_id: "cmd_embed_1".into(),
idempotency_key: Some("idem_embed".into()),
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
page_id: Some("page_2".into()),
block_id: None,
}),
payload: SavePageContent {
page_id: "page_2".into(),
workspace_id: Some("ws_1".into()),
revision: Some(7),
content_json: "{\"blocks\":[{\"id\":\"block_1\",\"type\":\"pageReference\"}]}".into(),
conflict_detection_key: Some("rev:7".into()),
},
reason: Some("嵌入页面".into()),
refs: vec!["checklist:p1-3".into()],
dry_run: false,
validate_only: false,
};
let request =
build_write_request(&demo_context(), &command).expect("write request should build");
assert_eq!(request.function_name, "documents:updateContent");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request.payload_json.contains("\"name\":\"documents.embed\""));
}
#[test]
fn page_lifecycle_commands_map_to_document_mutations() {
let base_actor = ActorPayload {
@@ -43,6 +43,7 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
"documents.template" => "documents:setTemplate",
"documents.emptyTrashByWorkspace" => "documents:emptyTrashByWorkspace",
"documents.purge" => "documents:purge",
"documents.embed" => "documents:updateContent",
"blocks.patch" => "documents:updateContent",
"documents.save" => "documents:updateContent",
"documents.title.update" => "documents:updateTitle",