chore: snapshot audited HoloLake convergence baseline

This commit is contained in:
冰朔 2026-08-18 17:58:07 +08:00
commit 1b6b17b5ab
47 changed files with 10722 additions and 87 deletions

View file

@ -268,6 +268,22 @@ dependencies = [
"system-deps",
]
[[package]]
name = "calamine"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1"
dependencies = [
"byteorder",
"chrono",
"codepage",
"encoding_rs",
"log",
"quick-xml 0.31.0",
"serde",
"zip 2.4.2",
]
[[package]]
name = "camino"
version = "1.2.5"
@ -393,6 +409,15 @@ dependencies = [
"cc",
]
[[package]]
name = "codepage"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
dependencies = [
"encoding_rs",
]
[[package]]
name = "combine"
version = "4.6.7"
@ -570,6 +595,27 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "csv"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "ctor"
version = "0.8.0"
@ -890,6 +936,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -1495,13 +1550,17 @@ name = "hololake-native-desktop"
version = "0.4.1"
dependencies = [
"base64 0.22.1",
"calamine",
"csv",
"dirs",
"encoding_rs",
"fs2",
"futures-util",
"interprocess",
"reqwest",
"ring",
"rusqlite",
"rust_xlsxwriter",
"serde",
"serde_json",
"tauri",
@ -2833,7 +2892,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.41.0",
"serde",
"time",
]
@ -2980,6 +3039,16 @@ dependencies = [
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
dependencies = [
"encoding_rs",
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@ -3350,6 +3419,17 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust_xlsxwriter"
version = "0.64.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f47f5318c1e512e57c07781559367577b1eb9618325cf1574cd30d38b112c5"
dependencies = [
"lazy_static",
"regex",
"zip 0.6.6",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@ -4310,7 +4390,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@ -5846,6 +5926,35 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
"flate2",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.14.0",
"memchr",
"thiserror 2.0.20",
"zopfli",
]
[[package]]
name = "zip"
version = "4.6.1"
@ -5863,3 +5972,15 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View file

@ -21,6 +21,9 @@ interprocess = "2.4.2"
ring = "0.17"
rusqlite = { version = "0.31", features = ["bundled"] }
base64 = "0.22"
calamine = { version = "=0.26.1", features = ["dates"] }
csv = "=1.3.0"
encoding_rs = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "=2.10.2", features = ["devtools"] }
@ -33,6 +36,7 @@ url = "2"
reqwest = { version = "0.13.2", default-features = false, features = ["cookies", "form", "json", "rustls", "stream"] }
tokio = { version = "1", features = ["time"] }
futures-util = "0.3"
rust_xlsxwriter = "=0.64.2"
[target.'cfg(windows)'.dependencies]
widestring = "1"

View file

@ -0,0 +1,476 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//! 当前账号私人频道的长期成长模型。
//!
//! 这里只保存最小化的行为元数据,不保存文档正文、表格单元格、提示词、对话原文或凭据。
//! 基础算法可以持续形成模块亲和度;模型 API 未来只能提交适配提案,不能绕过本地校验和人类确认。
use ring::digest::{digest, SHA256};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::AppHandle;
use uuid::Uuid;
const GROWTH_SCHEMA: &str = "hololake.channel-growth-model/v1";
const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordChannelGrowthEventInput {
pub event_kind: String,
pub module_id: String,
pub source: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateChannelGrowthSharingInput {
pub sharing_mode: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelGrowthControls {
pub sharing_mode: String,
pub official_read_access: bool,
pub history_mutation_allowed: bool,
pub structural_change_requires_human_confirmation: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelModuleAffinity {
pub module_id: String,
pub use_count: i64,
pub score: f64,
pub last_used_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelGrowthSnapshot {
pub schema: &'static str,
pub state: &'static str,
pub controls: ChannelGrowthControls,
pub observed_event_count: i64,
pub persona_collaboration_event_count: i64,
pub module_affinities: Vec<ChannelModuleAffinity>,
pub recommended_module_order: Vec<String>,
pub integrity_state: &'static str,
pub last_event_hash: String,
pub model_api_state: &'static str,
pub model_proposal_policy: &'static str,
pub data_boundary: &'static str,
}
#[tauri::command]
pub async fn get_channel_growth_snapshot(app: AppHandle) -> Result<ChannelGrowthSnapshot, String> {
let database = channel_growth_database(&app)?;
tauri::async_runtime::spawn_blocking(move || snapshot_at(&database))
.await
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn record_channel_growth_event(
app: AppHandle,
input: RecordChannelGrowthEventInput,
) -> Result<ChannelGrowthSnapshot, String> {
let database = channel_growth_database(&app)?;
tauri::async_runtime::spawn_blocking(move || record_event_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn update_channel_growth_sharing(
app: AppHandle,
input: UpdateChannelGrowthSharingInput,
) -> Result<ChannelGrowthSnapshot, String> {
let database = channel_growth_database(&app)?;
tauri::async_runtime::spawn_blocking(move || update_sharing_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))?
}
fn channel_growth_database(app: &AppHandle) -> Result<PathBuf, String> {
let root = crate::authenticated_storage::account_storage_root(app, "channel-growth-v1")?;
fs::create_dir_all(&root)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_STORAGE_UNAVAILABLE: {error}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_PERMISSION_FAILED: {error}"))?;
}
Ok(root.join("channel-growth.sqlite3"))
}
fn open_database(path: &Path) -> Result<Connection, String> {
let connection = Connection::open(path)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_DATABASE_UNAVAILABLE: {error}"))?;
connection
.busy_timeout(Duration::from_secs(5))
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_DATABASE_UNAVAILABLE: {error}"))?;
connection
.execute_batch(
"PRAGMA journal_mode = DELETE;
PRAGMA synchronous = FULL;
PRAGMA trusted_schema = OFF;
CREATE TABLE IF NOT EXISTS growth_sharing (
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
sharing_mode TEXT NOT NULL CHECK(sharing_mode IN ('LOCAL_ONLY', 'ANONYMIZED_SHARED'))
);
INSERT OR IGNORE INTO growth_sharing VALUES (1, 'LOCAL_ONLY');
CREATE TABLE IF NOT EXISTS growth_events (
sequence INTEGER NOT NULL UNIQUE,
event_id TEXT PRIMARY KEY NOT NULL,
event_kind TEXT NOT NULL,
module_id TEXT NOT NULL,
source TEXT NOT NULL CHECK(source IN ('HUMAN_ACTION', 'PERSONA_COLLABORATION', 'SYSTEM_ASSISTED')),
occurred_at_unix_ms INTEGER NOT NULL,
previous_hash TEXT NOT NULL,
event_hash TEXT NOT NULL UNIQUE
);
CREATE TRIGGER IF NOT EXISTS growth_events_no_update BEFORE UPDATE ON growth_events BEGIN SELECT RAISE(ABORT, 'HOLOLAKE_GROWTH_HISTORY_APPEND_ONLY'); END;
CREATE TRIGGER IF NOT EXISTS growth_events_no_delete BEFORE DELETE ON growth_events BEGIN SELECT RAISE(ABORT, 'HOLOLAKE_GROWTH_HISTORY_APPEND_ONLY'); END;
CREATE TABLE IF NOT EXISTS module_affinities (
module_id TEXT PRIMARY KEY NOT NULL,
use_count INTEGER NOT NULL,
score REAL NOT NULL,
last_used_at_unix_ms INTEGER NOT NULL
);",
)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_SCHEMA_FAILED: {error}"))?;
Ok(connection)
}
fn validate_token(value: &str, label: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty()
|| value.len() > 96
|| !value
.chars()
.all(|item| item.is_ascii_alphanumeric() || matches!(item, '-' | '_' | '.' | ':'))
{
return Err(format!("HOLOLAKE_CHANNEL_GROWTH_{label}_INVALID"));
}
Ok(value.to_string())
}
fn record_event_at(
database: &Path,
input: RecordChannelGrowthEventInput,
) -> Result<ChannelGrowthSnapshot, String> {
let event_kind = validate_token(&input.event_kind, "EVENT_KIND")?;
let module_id = validate_token(&input.module_id, "MODULE_ID")?;
let source = input.source.trim().to_string();
if !matches!(
source.as_str(),
"HUMAN_ACTION" | "PERSONA_COLLABORATION" | "SYSTEM_ASSISTED"
) {
return Err("HOLOLAKE_CHANNEL_GROWTH_SOURCE_INVALID".into());
}
let mut connection = open_database(database)?;
verify_event_chain(&connection)?;
let now = now_unix_ms()?;
let weight = if source == "PERSONA_COLLABORATION" {
1.4
} else {
1.0
};
let transaction = connection
.transaction()
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_TRANSACTION_FAILED: {error}"))?;
let (sequence, previous_hash): (i64, String) = transaction
.query_row(
"SELECT COALESCE(MAX(sequence), 0) + 1, COALESCE((SELECT event_hash FROM growth_events ORDER BY sequence DESC LIMIT 1), ?1) FROM growth_events",
params![ZERO_HASH],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let event_id = Uuid::new_v4().to_string();
let event_hash = event_hash(
sequence,
&event_id,
&event_kind,
&module_id,
&source,
now,
&previous_hash,
);
transaction
.execute(
"INSERT INTO growth_events(sequence, event_id, event_kind, module_id, source, occurred_at_unix_ms, previous_hash, event_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![sequence, event_id, event_kind, module_id, source, now, previous_hash, event_hash],
)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_EVENT_WRITE_FAILED: {error}"))?;
transaction
.execute(
"INSERT INTO module_affinities(module_id, use_count, score, last_used_at_unix_ms) VALUES (?1, 1, ?2, ?3)
ON CONFLICT(module_id) DO UPDATE SET use_count = use_count + 1, score = score * 0.985 + excluded.score, last_used_at_unix_ms = excluded.last_used_at_unix_ms",
params![module_id, weight, now],
)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_MODEL_WRITE_FAILED: {error}"))?;
transaction
.commit()
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_COMMIT_FAILED: {error}"))?;
snapshot_with_connection(&connection)
}
fn update_sharing_at(
database: &Path,
input: UpdateChannelGrowthSharingInput,
) -> Result<ChannelGrowthSnapshot, String> {
if !matches!(
input.sharing_mode.as_str(),
"LOCAL_ONLY" | "ANONYMIZED_SHARED"
) {
return Err("HOLOLAKE_CHANNEL_GROWTH_SHARING_MODE_INVALID".into());
}
let connection = open_database(database)?;
connection
.execute(
"UPDATE growth_sharing SET sharing_mode = ?1 WHERE singleton = 1",
params![input.sharing_mode],
)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_SHARING_WRITE_FAILED: {error}"))?;
snapshot_with_connection(&connection)
}
fn snapshot_at(database: &Path) -> Result<ChannelGrowthSnapshot, String> {
let connection = open_database(database)?;
snapshot_with_connection(&connection)
}
fn snapshot_with_connection(connection: &Connection) -> Result<ChannelGrowthSnapshot, String> {
let last_event_hash = verify_event_chain(connection)?;
let controls = connection
.query_row(
"SELECT sharing_mode FROM growth_sharing WHERE singleton = 1",
[],
|row| {
Ok(ChannelGrowthControls {
sharing_mode: row.get(0)?,
official_read_access: false,
history_mutation_allowed: false,
structural_change_requires_human_confirmation: true,
})
},
)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let observed_event_count = connection
.query_row("SELECT COUNT(*) FROM growth_events", [], |row| row.get(0))
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let persona_collaboration_event_count = connection
.query_row(
"SELECT COUNT(*) FROM growth_events WHERE source = 'PERSONA_COLLABORATION'",
[],
|row| row.get(0),
)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let mut statement = connection
.prepare("SELECT module_id, use_count, score, last_used_at_unix_ms FROM module_affinities ORDER BY score DESC, last_used_at_unix_ms DESC LIMIT 12")
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let affinities = statement
.query_map([], |row| {
Ok(ChannelModuleAffinity {
module_id: row.get(0)?,
use_count: row.get(1)?,
score: row.get(2)?,
last_used_at_unix_ms: row.get(3)?,
})
})
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let recommended_module_order = affinities
.iter()
.map(|item| item.module_id.clone())
.collect();
Ok(ChannelGrowthSnapshot {
schema: GROWTH_SCHEMA,
state: "APPEND_ONLY_LOCAL_GROWTH_ACTIVE",
controls,
observed_event_count,
persona_collaboration_event_count,
module_affinities: affinities,
recommended_module_order,
integrity_state: "VERIFIED_APPEND_ONLY_CHAIN",
last_event_hash,
model_api_state: "NOT_CONFIGURED",
model_proposal_policy: "LOCAL_VALIDATE_THEN_HUMAN_CONFIRM_BEFORE_STRUCTURAL_CHANGE",
data_boundary: "METADATA_ONLY_NO_CONTENT_NO_CREDENTIALS_NO_CONVERSATION_TRANSCRIPT",
})
}
fn event_hash(
sequence: i64,
event_id: &str,
event_kind: &str,
module_id: &str,
source: &str,
occurred_at_unix_ms: i64,
previous_hash: &str,
) -> String {
let material = format!("{GROWTH_SCHEMA}|{sequence}|{event_id}|{event_kind}|{module_id}|{source}|{occurred_at_unix_ms}|{previous_hash}");
hex(digest(&SHA256, material.as_bytes()).as_ref())
}
fn verify_event_chain(connection: &Connection) -> Result<String, String> {
let mut statement = connection
.prepare("SELECT sequence, event_id, event_kind, module_id, source, occurred_at_unix_ms, previous_hash, event_hash FROM growth_events ORDER BY sequence")
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let mut rows = statement
.query([])
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let mut expected_sequence = 1_i64;
let mut previous = ZERO_HASH.to_string();
while let Some(row) = rows
.next()
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?
{
let sequence: i64 = row
.get(0)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let event_id: String = row
.get(1)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let event_kind: String = row
.get(2)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let module_id: String = row
.get(3)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let source: String = row
.get(4)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let occurred_at: i64 = row
.get(5)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let stored_previous: String = row
.get(6)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
let stored_hash: String = row
.get(7)
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
if sequence != expected_sequence
|| stored_previous != previous
|| stored_hash
!= event_hash(
sequence,
&event_id,
&event_kind,
&module_id,
&source,
occurred_at,
&stored_previous,
)
{
return Err("HOLOLAKE_CHANNEL_GROWTH_HISTORY_INTEGRITY_FAILED".into());
}
expected_sequence += 1;
previous = stored_hash;
}
Ok(previous)
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn now_unix_ms() -> Result<i64, String> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))?
.as_millis() as i64)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn local_growth_model_records_only_bounded_metadata_and_ranks_modules() {
let dir = tempdir().unwrap();
let database = dir.path().join("growth.sqlite3");
for _ in 0..3 {
record_event_at(
&database,
RecordChannelGrowthEventInput {
event_kind: "MODULE_OPEN".into(),
module_id: "education.table".into(),
source: "HUMAN_ACTION".into(),
},
)
.unwrap();
}
record_event_at(
&database,
RecordChannelGrowthEventInput {
event_kind: "PERSONA_COLLABORATION".into(),
module_id: "knowledge".into(),
source: "PERSONA_COLLABORATION".into(),
},
)
.unwrap();
let snapshot = snapshot_at(&database).unwrap();
assert_eq!(snapshot.observed_event_count, 4);
assert_eq!(snapshot.persona_collaboration_event_count, 1);
assert_eq!(
snapshot
.recommended_module_order
.first()
.map(String::as_str),
Some("education.table")
);
assert_eq!(
snapshot.data_boundary,
"METADATA_ONLY_NO_CONTENT_NO_CREDENTIALS_NO_CONVERSATION_TRANSCRIPT"
);
}
#[test]
fn growth_history_rejects_update_and_delete() {
let dir = tempdir().unwrap();
let database = dir.path().join("growth.sqlite3");
record_event_at(
&database,
RecordChannelGrowthEventInput {
event_kind: "MODULE_OPEN".into(),
module_id: "knowledge".into(),
source: "HUMAN_ACTION".into(),
},
)
.unwrap();
let connection = open_database(&database).unwrap();
assert!(connection.execute("DELETE FROM growth_events", []).is_err());
assert!(connection
.execute("UPDATE growth_events SET module_id = 'rewritten'", [])
.is_err());
assert_eq!(
snapshot_at(&database).unwrap().integrity_state,
"VERIFIED_APPEND_ONLY_CHAIN"
);
}
#[test]
fn sharing_choice_does_not_grant_official_read_or_mutate_history() {
let dir = tempdir().unwrap();
let database = dir.path().join("growth.sqlite3");
let snapshot = update_sharing_at(
&database,
UpdateChannelGrowthSharingInput {
sharing_mode: "ANONYMIZED_SHARED".into(),
},
)
.unwrap();
assert_eq!(snapshot.controls.sharing_mode, "ANONYMIZED_SHARED");
assert!(!snapshot.controls.official_read_access);
assert!(!snapshot.controls.history_mutation_allowed);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
use ring::digest::{digest, SHA256};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::ffi::OsStr;
use std::fs::{self, OpenOptions};
use std::io::Write;
@ -11,7 +11,7 @@ use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::time::UNIX_EPOCH;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use uuid::Uuid;
@ -22,6 +22,11 @@ const MAX_IMPORT_FILES: usize = 1_000;
const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
const MAX_READ_BYTES: u64 = 2 * 1024 * 1024;
const MAX_SEARCH_RESULTS: usize = 100;
const ORGANIZATION_SCHEMA: &str = "hololake.knowledge-organization/v1";
const MAX_CATEGORY_DEPTH: usize = 4;
const MAX_CATEGORY_SEGMENT_CHARS: usize = 40;
const MAX_TAGS_PER_DOCUMENT: usize = 12;
const MAX_TAG_CHARS: usize = 24;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@ -33,6 +38,24 @@ pub struct KnowledgeDocumentSummary {
pub size_bytes: u64,
pub content_sha256: String,
pub duplicate_count: usize,
pub category: String,
pub category_explicit: bool,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeCategorySummary {
pub name: String,
pub document_count: usize,
pub explicitly_organized_count: usize,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeTagSummary {
pub name: String,
pub document_count: usize,
}
#[derive(Clone, Debug, Serialize)]
@ -47,9 +70,54 @@ pub struct KnowledgeSnapshot {
pub raw_document_count: usize,
pub unique_document_count: usize,
pub duplicate_document_count: usize,
pub organized_document_count: usize,
pub categories: Vec<KnowledgeCategorySummary>,
pub tags: Vec<KnowledgeTagSummary>,
pub organization_revision: String,
pub truncated: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
struct KnowledgeOrganizationEntry {
category: String,
tags: Vec<String>,
updated_at_unix_ms: u128,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct KnowledgeOrganizationIndex {
schema: String,
documents: BTreeMap<String, KnowledgeOrganizationEntry>,
}
impl Default for KnowledgeOrganizationIndex {
fn default() -> Self {
Self {
schema: ORGANIZATION_SCHEMA.into(),
documents: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateKnowledgeOrganizationInput {
pub source: String,
pub path: String,
pub expected_content_sha256: String,
pub category: String,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeOrganizationUpdateResult {
pub schema: &'static str,
pub state: &'static str,
pub git_commit: String,
pub snapshot: KnowledgeSnapshot,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ReadKnowledgeDocumentInput {
@ -145,6 +213,13 @@ pub async fn get_knowledge_snapshot(app: AppHandle) -> Result<KnowledgeSnapshot,
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
}
pub(crate) fn snapshot_for_native_composition(
app: &AppHandle,
) -> Result<KnowledgeSnapshot, String> {
let roots = knowledge_roots(app)?;
snapshot_at(&roots.0, roots.1.as_deref())
}
#[tauri::command]
pub async fn read_knowledge_document(
app: AppHandle,
@ -180,6 +255,19 @@ pub async fn save_knowledge_document(
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn update_knowledge_organization(
app: AppHandle,
input: UpdateKnowledgeOrganizationInput,
) -> Result<KnowledgeOrganizationUpdateResult, String> {
let roots = knowledge_roots(&app)?;
tauri::async_runtime::spawn_blocking(move || {
update_organization_at(&roots.0, roots.1.as_deref(), input)
})
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_JOIN_FAILED: {error}"))?
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportKnowledgeDocumentInput {
@ -219,22 +307,50 @@ pub async fn export_knowledge_document(
suggested = "知识页".to_string();
}
// Windows 保留设备名CON/NUL/COM1…不能直接当文件名撞名就补个尾巴。
if matches!(suggested.to_ascii_uppercase().as_str(),
"CON" | "PRN" | "AUX" | "NUL"
| "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9"
| "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9") {
if matches!(
suggested.to_ascii_uppercase().as_str(),
"CON"
| "PRN"
| "AUX"
| "NUL"
| "COM1"
| "COM2"
| "COM3"
| "COM4"
| "COM5"
| "COM6"
| "COM7"
| "COM8"
| "COM9"
| "LPT1"
| "LPT2"
| "LPT3"
| "LPT4"
| "LPT5"
| "LPT6"
| "LPT7"
| "LPT8"
| "LPT9"
) {
suggested.push_str("·页");
}
suggested.push('.');
suggested.push_str(extension);
let filter_label = if extension == "html" { "网页文件" } else { "Markdown" };
let filter_label = if extension == "html" {
"网页文件"
} else {
"Markdown"
};
let (sender, receiver) = std::sync::mpsc::channel::<Option<PathBuf>>();
app.dialog()
.file()
.set_file_name(&suggested)
.add_filter(filter_label, &[extension])
.save_file(move |selection| {
let picked = selection.as_ref().and_then(|path| path.as_path()).map(|path| path.to_path_buf());
let picked = selection
.as_ref()
.and_then(|path| path.as_path())
.map(|path| path.to_path_buf());
let _ = sender.send(picked);
});
let picked = tauri::async_runtime::spawn_blocking(move || receiver.recv().ok().flatten())
@ -259,6 +375,7 @@ pub async fn export_knowledge_document(
pub struct DeleteKnowledgeDocumentInput {
pub source: String,
pub path: String,
pub confirmed: bool,
}
#[derive(Clone, Debug, Serialize)]
@ -279,22 +396,6 @@ fn resolve_native_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
Ok(target)
}
async fn confirm_destructive(app: &AppHandle, message: String) -> Result<bool, String> {
let confirmer = app.clone();
let confirmed = tauri::async_runtime::spawn_blocking(move || {
confirmer
.dialog()
.message(message)
.title("删除确认")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom("确认删除".to_string(), "取消".to_string()))
.blocking_show()
})
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?;
Ok(confirmed)
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateKnowledgeDocumentInput {
@ -308,7 +409,11 @@ pub async fn create_knowledge_document(
) -> Result<KnowledgeDeleteReceipt, String> {
// 新建空白页:标题撞名自动补序号,落进本机库并进 Git 托管。
let roots = knowledge_roots(&app)?;
let base = if input.title.trim().is_empty() { "未命名页面".to_string() } else { input.title.trim().to_string() };
let base = if input.title.trim().is_empty() {
"未命名页面".to_string()
} else {
input.title.trim().to_string()
};
let docs = roots.0.join("docs");
let mut name = base.clone();
let mut counter = 2u32;
@ -325,14 +430,22 @@ pub async fn create_knowledge_document(
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
file.write_all(body.as_bytes())
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
file.sync_all().map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
file.sync_all()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
git(&roots.0, &["add", "--", &format!("docs/{name}.md")], "ADD")?;
git(&roots.0, &["commit", "-m", &format!("新建页面:{name}")], "COMMIT")?;
git(
&roots.0,
&["commit", "-m", &format!("新建页面:{name}")],
"COMMIT",
)?;
Ok::<(), String>(())
})
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_JOIN_FAILED: {error}"))??;
Ok(KnowledgeDeleteReceipt { removed: format!("{receipt_name}.md"), pages: 1 })
Ok(KnowledgeDeleteReceipt {
removed: format!("{receipt_name}.md"),
pages: 1,
})
}
#[tauri::command]
@ -345,26 +458,25 @@ pub async fn delete_knowledge_document(
return Err("HOLOLAKE_KNOWLEDGE_DELETE_READ_ONLY_SOURCE".into());
}
let roots = knowledge_roots(&app)?;
let target = resolve_native_path(&roots.0, &input.path)?;
let target = resolve_native_path(&roots.0.join("docs"), &input.path)?;
if !target.is_file() {
return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_NOT_FOUND".into());
}
let title = target
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("这一页")
.to_string();
if !confirm_destructive(&app, format!("把页面「{title}」移到回收站?随时可以找回。")).await? {
if !input.confirmed {
return Ok(None);
}
move_to_trash(&roots.0, &target)?;
Ok(Some(KnowledgeDeleteReceipt { removed: input.path, pages: 1 }))
archive_native_entry(&roots.0, &target, &input.path, false)?;
Ok(Some(KnowledgeDeleteReceipt {
removed: input.path,
pages: 1,
}))
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteKnowledgeFolderInput {
pub folder: String,
pub confirmed: bool,
}
#[tauri::command]
@ -374,15 +486,11 @@ pub async fn delete_knowledge_folder(
) -> Result<Option<KnowledgeDeleteReceipt>, String> {
// 删整个文件夹 = 删本机库里该相对目录及其全部页面。
let roots = knowledge_roots(&app)?;
let target = resolve_native_path(&roots.0, &input.folder)?;
if target == roots.0 || !target.is_dir() {
let docs_root = roots.0.join("docs");
let target = resolve_native_path(&docs_root, &input.folder)?;
if target == docs_root || !target.is_dir() {
return Err("HOLOLAKE_KNOWLEDGE_FOLDER_NOT_FOUND".into());
}
let name = target
.file_name()
.and_then(OsStr::to_str)
.unwrap_or(&input.folder)
.to_string();
let pages = fs::read_dir(&target)
.map(|entries| {
entries
@ -391,11 +499,14 @@ pub async fn delete_knowledge_folder(
.count()
})
.unwrap_or(0);
if !confirm_destructive(&app, format!("把文件夹「{name}」和里面全部 {pages} 个页面移到回收站?随时可以找回。")).await? {
if !input.confirmed {
return Ok(None);
}
move_to_trash(&roots.0, &target)?;
Ok(Some(KnowledgeDeleteReceipt { removed: input.folder, pages }))
archive_native_entry(&roots.0, &target, &input.folder, true)?;
Ok(Some(KnowledgeDeleteReceipt {
removed: input.folder,
pages,
}))
}
#[tauri::command]
@ -404,7 +515,9 @@ pub async fn print_knowledge_document(app: AppHandle) -> Result<(), String> {
let window = app
.get_webview_window("main")
.ok_or_else(|| "HOLOLAKE_PRINT_WINDOW_NOT_FOUND".to_string())?;
window.print().map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}"))
window
.print()
.map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}"))
}
#[tauri::command]
@ -476,7 +589,9 @@ fn move_to_trash(root: &Path, target: &Path) -> Result<PathBuf, String> {
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
let destination = root.join(".trash").join(format!("{}-{stamp}", relative.display()));
let destination = root
.join(".trash")
.join(format!("{}-{stamp}", relative.display()));
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
@ -486,9 +601,67 @@ fn move_to_trash(root: &Path, target: &Path) -> Result<PathBuf, String> {
Ok(destination)
}
/// 把 docs 下的页面或目录移到库内回收站,同时清理对应的整理索引并留下 Git 回执。
/// 文件移动与索引写入之间任一步失败,都会尽力把原件放回原位。
fn archive_native_entry(
root: &Path,
target: &Path,
relative_docs_path: &str,
directory: bool,
) -> Result<PathBuf, String> {
let (mut organization, _) = load_organization_index(root)?;
let previous_organization = organization.clone();
let prefix = format!("{}/", relative_docs_path.trim_end_matches('/'));
if directory {
organization
.documents
.retain(|path, _| path != relative_docs_path && !path.starts_with(&prefix));
} else {
organization.documents.remove(relative_docs_path);
}
let destination = move_to_trash(root, target)?;
if let Err(error) = write_organization_index(root, &organization) {
let _ = fs::rename(&destination, target);
let _ = write_organization_index(root, &previous_organization);
return Err(error);
}
let docs_pathspec = format!("docs/{relative_docs_path}");
let trash_pathspec = destination
.strip_prefix(root)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?
.to_string_lossy()
.replace('\\', "/");
git(
root,
&[
"add",
"-A",
"--",
&docs_pathspec,
&trash_pathspec,
".hololake/organization-v1.json",
],
"ARCHIVE_ADD",
)?;
git(
root,
&[
"commit",
"-m",
&format!("knowledge: archive {relative_docs_path}"),
],
"ARCHIVE_COMMIT",
)?;
Ok(destination)
}
fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> {
fs::create_dir_all(root.join("docs").join("导入"))
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?;
fs::create_dir_all(root.join(".hololake"))
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?;
#[cfg(unix)]
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?;
@ -504,12 +677,251 @@ fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> {
Ok(())
}
fn organization_index_path(root: &Path) -> PathBuf {
root.join(".hololake").join("organization-v1.json")
}
fn load_organization_index(root: &Path) -> Result<(KnowledgeOrganizationIndex, String), String> {
let path = organization_index_path(root);
if !path.exists() {
let index = KnowledgeOrganizationIndex::default();
let bytes = serde_json::to_vec(&index).map_err(|error| {
format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_SERIALIZE_FAILED: {error}")
})?;
return Ok((index, sha256_hex(&bytes)));
}
let bytes = fs::read(&path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_READ_FAILED: {error}"))?;
if bytes.len() > 2 * 1024 * 1024 {
return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_TOO_LARGE".into());
}
let index = serde_json::from_slice::<KnowledgeOrganizationIndex>(&bytes)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_INVALID: {error}"))?;
if index.schema != ORGANIZATION_SCHEMA {
return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_SCHEMA_UNSUPPORTED".into());
}
Ok((index, sha256_hex(&bytes)))
}
fn normalize_category(value: &str) -> Result<String, String> {
let segments = value
.split(['/', '\\'])
.map(str::trim)
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if segments.is_empty() || segments.len() > MAX_CATEGORY_DEPTH {
return Err("HOLOLAKE_KNOWLEDGE_CATEGORY_INVALID".into());
}
for segment in &segments {
let character_count = segment.chars().count();
if character_count == 0
|| character_count > MAX_CATEGORY_SEGMENT_CHARS
|| matches!(*segment, "." | "..")
|| segment
.chars()
.any(|character| character.is_control() || ":*?\"<>|".contains(character))
{
return Err("HOLOLAKE_KNOWLEDGE_CATEGORY_INVALID".into());
}
}
Ok(segments.join(" / "))
}
fn normalize_tags(tags: Vec<String>) -> Result<Vec<String>, String> {
if tags.len() > MAX_TAGS_PER_DOCUMENT {
return Err("HOLOLAKE_KNOWLEDGE_TAGS_INVALID".into());
}
let mut seen = HashSet::new();
let mut normalized = Vec::new();
for tag in tags {
let value = tag.trim().trim_start_matches('#').trim().to_string();
if value.is_empty() {
continue;
}
if value.chars().count() > MAX_TAG_CHARS
|| value.chars().any(|character| character.is_control())
{
return Err("HOLOLAKE_KNOWLEDGE_TAGS_INVALID".into());
}
let key = value.to_lowercase();
if seen.insert(key) {
normalized.push(value);
}
}
normalized.sort();
Ok(normalized)
}
fn derived_category(path: &str) -> String {
let parts = path
.split('/')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.first().copied() == Some("导入") {
return parts.get(2).copied().unwrap_or("根目录").to_string();
}
if parts.len() > 1 {
parts[0].to_string()
} else {
"根目录".into()
}
}
fn frontmatter_tags(bytes: &[u8]) -> Vec<String> {
let Ok(body) = std::str::from_utf8(bytes) else {
return Vec::new();
};
let normalized = body.replace("\r\n", "\n");
if !normalized.starts_with("---\n") {
return Vec::new();
}
let Some(closing) = normalized[4..].find("\n---\n").map(|position| position + 4) else {
return Vec::new();
};
let mut tags = Vec::new();
let mut reading_list = false;
for line in normalized[4..closing].lines() {
let trimmed = line.trim();
if reading_list && trimmed.starts_with('-') {
tags.push(
trimmed
.trim_start_matches('-')
.trim()
.trim_matches(['\'', '"'])
.to_string(),
);
continue;
}
reading_list = false;
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
if key.trim() != "tags" {
continue;
}
let value = value.trim();
if value.is_empty() {
reading_list = true;
continue;
}
let inline = value.trim_matches(['[', ']']);
tags.extend(
inline
.split(',')
.map(|tag| tag.trim().trim_matches(['\'', '"']).to_string()),
);
}
normalize_tags(tags).unwrap_or_default()
}
fn replace_organization_file(target: &Path, bytes: &[u8]) -> Result<(), String> {
let parent = target
.parent()
.ok_or_else(|| "HOLOLAKE_KNOWLEDGE_ORGANIZATION_PATH_INVALID".to_string())?;
fs::create_dir_all(parent)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?;
let temporary = parent.join(format!("organization-{}.tmp", Uuid::new_v4()));
let mut file = private_file_options()
.open(&temporary)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?;
file.write_all(bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?;
if !target.exists() {
return fs::rename(&temporary, target)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"));
}
let backup = parent.join(format!("organization-{}.backup", Uuid::new_v4()));
fs::rename(target, &backup)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?;
if let Err(error) = fs::rename(&temporary, target) {
let _ = fs::rename(&backup, target);
let _ = fs::remove_file(&temporary);
return Err(format!(
"HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"
));
}
let _ = fs::remove_file(backup);
Ok(())
}
fn write_organization_index(root: &Path, index: &KnowledgeOrganizationIndex) -> Result<(), String> {
let mut bytes = serde_json::to_vec_pretty(index)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_SERIALIZE_FAILED: {error}"))?;
bytes.push(b'\n');
replace_organization_file(&organization_index_path(root), &bytes)
}
fn update_organization_at(
native: &Path,
legacy: Option<&Path>,
input: UpdateKnowledgeOrganizationInput,
) -> Result<KnowledgeOrganizationUpdateResult, String> {
if input.source != "native" {
return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_READ_ONLY_SOURCE".into());
}
let category = normalize_category(&input.category)?;
let tags = normalize_tags(input.tags)?;
let document_path = safe_document_path(&native.join("docs"), &input.path)?;
let document_bytes = fs::read(&document_path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?;
if sha256_hex(&document_bytes) != input.expected_content_sha256 {
return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_CONFLICT".into());
}
let (mut index, _) = load_organization_index(native)?;
let unchanged = index
.documents
.get(&input.path)
.map(|entry| entry.category == category && entry.tags == tags)
.unwrap_or(false);
if !unchanged {
let updated_at_unix_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
index.documents.insert(
input.path.clone(),
KnowledgeOrganizationEntry {
category,
tags,
updated_at_unix_ms,
},
);
write_organization_index(native, &index)?;
git(
native,
&["add", "--", ".hololake/organization-v1.json"],
"ORGANIZATION_ADD",
)?;
git(
native,
&[
"commit",
"-m",
&format!("knowledge: organize {}", input.path),
],
"ORGANIZATION_COMMIT",
)?;
}
let git_commit = git(native, &["rev-parse", "HEAD"], "ORGANIZATION_READBACK")?
.trim()
.to_string();
Ok(KnowledgeOrganizationUpdateResult {
schema: "hololake.knowledge-organization-update/v1",
state: if unchanged { "UNCHANGED" } else { "SAVED" },
git_commit,
snapshot: snapshot_at(native, legacy)?,
})
}
fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result<KnowledgeSnapshot, String> {
let (organization, organization_revision) = load_organization_index(native)?;
let mut raw_documents = Vec::new();
collect_documents(
&native.join("docs"),
&native.join("docs"),
"native",
Some(&organization),
&mut raw_documents,
)?;
if let Some(root) = legacy {
@ -517,6 +929,7 @@ fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result<KnowledgeSnapshot
&root.join("docs"),
&root.join("docs"),
"legacy",
None,
&mut raw_documents,
)?;
}
@ -539,6 +952,53 @@ fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result<KnowledgeSnapshot
let truncated = raw_document_count >= MAX_TREE_DOCUMENTS;
documents.truncate(MAX_TREE_DOCUMENTS);
let unique_document_count = documents.len();
let organized_document_count = documents
.iter()
.filter(|document| document.category_explicit)
.count();
let mut category_counts = BTreeMap::<String, (usize, usize)>::new();
let mut tag_counts = BTreeMap::<String, usize>::new();
for document in &documents {
let category = category_counts
.entry(document.category.clone())
.or_default();
category.0 += 1;
if document.category_explicit {
category.1 += 1;
}
for tag in &document.tags {
*tag_counts.entry(tag.clone()).or_default() += 1;
}
}
let mut categories = category_counts
.into_iter()
.map(
|(name, (document_count, explicitly_organized_count))| KnowledgeCategorySummary {
name,
document_count,
explicitly_organized_count,
},
)
.collect::<Vec<_>>();
categories.sort_by(|left, right| {
right
.document_count
.cmp(&left.document_count)
.then_with(|| left.name.cmp(&right.name))
});
let mut tags = tag_counts
.into_iter()
.map(|(name, document_count)| KnowledgeTagSummary {
name,
document_count,
})
.collect::<Vec<_>>();
tags.sort_by(|left, right| {
right
.document_count
.cmp(&left.document_count)
.then_with(|| left.name.cmp(&right.name))
});
Ok(KnowledgeSnapshot {
schema: SNAPSHOT_SCHEMA,
state: "READY",
@ -549,6 +1009,10 @@ fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result<KnowledgeSnapshot
raw_document_count,
unique_document_count,
duplicate_document_count: raw_document_count.saturating_sub(unique_document_count),
organized_document_count,
categories,
tags,
organization_revision,
truncated,
})
}
@ -557,6 +1021,7 @@ fn collect_documents(
root: &Path,
current: &Path,
source: &'static str,
organization: Option<&KnowledgeOrganizationIndex>,
output: &mut Vec<KnowledgeDocumentSummary>,
) -> Result<(), String> {
if output.len() >= MAX_TREE_DOCUMENTS {
@ -581,7 +1046,7 @@ fn collect_documents(
let name = entry.file_name();
if kind.is_dir() {
if name != OsStr::new(".git") && name != OsStr::new(".hololake") {
collect_documents(root, &path, source, output)?;
collect_documents(root, &path, source, organization, output)?;
}
continue;
}
@ -594,9 +1059,22 @@ fn collect_documents(
if metadata.len() > MAX_READ_BYTES {
continue;
}
let relative = relative_posix(root, &path)?;
let bytes = fs::read(&path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?;
let relative = relative_posix(root, &path)?;
let frontmatter_tags = frontmatter_tags(&bytes);
let indexed = organization.and_then(|index| index.documents.get(&relative));
let category = indexed
.map(|entry| entry.category.clone())
.filter(|category| !category.is_empty())
.unwrap_or_else(|| derived_category(&relative));
let mut tags = indexed.map(|entry| entry.tags.clone()).unwrap_or_default();
for tag in frontmatter_tags {
if !tags.contains(&tag) {
tags.push(tag);
}
}
tags.sort();
output.push(KnowledgeDocumentSummary {
source,
path: relative,
@ -605,6 +1083,9 @@ fn collect_documents(
size_bytes: metadata.len(),
content_sha256: sha256_hex(&bytes),
duplicate_count: 0,
category,
category_explicit: indexed.is_some(),
tags,
});
}
Ok(())
@ -990,7 +1471,9 @@ fn strip_notion_links(line: &str) -> String {
let text_end = i + 1 + close;
if text_end + 1 < bytes.len() && bytes[text_end + 1] == '(' {
if let Some(paren_end) = bytes[text_end + 2..].iter().position(|c| *c == ')') {
let href: String = bytes[text_end + 2..text_end + 2 + paren_end].iter().collect();
let href: String = bytes[text_end + 2..text_end + 2 + paren_end]
.iter()
.collect();
if href.contains("notion.so") || href.contains("notion.site") {
out.extend(bytes[i + 1..text_end].iter());
i = text_end + 2 + paren_end + 1;
@ -1376,4 +1859,130 @@ mod tests {
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "星湖");
}
#[test]
fn snapshot_builds_unified_categories_and_frontmatter_tags() {
let native = tempdir().unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
fs::create_dir_all(native.path().join("docs/导入/旧库/课程")).unwrap();
fs::write(
native.path().join("docs/导入/旧库/课程/第一课.md"),
"---\ntags: [教学, 重点]\n---\n# 第一课\n",
)
.unwrap();
let snapshot = snapshot_at(native.path(), None).unwrap();
assert_eq!(snapshot.documents[0].category, "课程");
assert_eq!(snapshot.documents[0].tags, vec!["教学", "重点"]);
assert_eq!(snapshot.categories[0].name, "课程");
assert_eq!(snapshot.tags.len(), 2);
assert_eq!(snapshot.organized_document_count, 0);
}
#[test]
fn native_organization_is_revision_locked_git_backed_and_does_not_rewrite_body() {
let native = tempdir().unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
let body = "---\ntags: [原有]\n---\n# 页面\n\n正文不应被整理操作改写。\n";
fs::write(native.path().join("docs/page.md"), body).unwrap();
git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap();
git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap();
let hash = sha256_hex(body.as_bytes());
let result = update_organization_at(
native.path(),
None,
UpdateKnowledgeOrganizationInput {
source: "native".into(),
path: "page.md".into(),
expected_content_sha256: hash.clone(),
category: "教学 / 课程".into(),
tags: vec!["待复盘".into(), "重点".into()],
},
)
.unwrap();
assert_eq!(result.state, "SAVED");
assert_eq!(result.git_commit.len(), 40);
assert_eq!(
fs::read_to_string(native.path().join("docs/page.md")).unwrap(),
body
);
let document = &result.snapshot.documents[0];
assert_eq!(document.category, "教学 / 课程");
assert!(document.category_explicit);
assert_eq!(document.tags, vec!["原有", "待复盘", "重点"]);
assert_eq!(result.snapshot.organized_document_count, 1);
let stale = update_organization_at(
native.path(),
None,
UpdateKnowledgeOrganizationInput {
source: "native".into(),
path: "page.md".into(),
expected_content_sha256: sha256_hex(b"stale"),
category: "其他".into(),
tags: vec![],
},
)
.unwrap_err();
assert!(stale.contains("ORGANIZATION_CONFLICT"));
}
#[test]
fn organized_page_archive_moves_into_recovery_cleans_index_and_commits() {
let native = tempdir().unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
let body = "# 待归档页面\n";
let target = native.path().join("docs/page.md");
fs::write(&target, body).unwrap();
git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap();
git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap();
update_organization_at(
native.path(),
None,
UpdateKnowledgeOrganizationInput {
source: "native".into(),
path: "page.md".into(),
expected_content_sha256: sha256_hex(body.as_bytes()),
category: "验收 / 归档".into(),
tags: vec!["可恢复".into()],
},
)
.unwrap();
let destination = archive_native_entry(native.path(), &target, "page.md", false).unwrap();
assert!(!target.exists());
assert!(destination.is_file());
assert!(destination.starts_with(native.path().join(".trash")));
let (organization, _) = load_organization_index(native.path()).unwrap();
assert!(!organization.documents.contains_key("page.md"));
assert_eq!(
snapshot_at(native.path(), None)
.unwrap()
.unique_document_count,
0
);
assert!(
git(native.path(), &["status", "--porcelain"], "TEST_STATUS")
.unwrap()
.trim()
.is_empty()
);
}
#[test]
fn organization_rejects_unbounded_or_cross_platform_invalid_labels() {
assert!(normalize_category("课程/章节/单元/课时/过深").is_err());
assert!(normalize_category("课程:非法").is_err());
assert!(normalize_tags(vec!["a".repeat(MAX_TAG_CHARS + 1)]).is_err());
assert!(normalize_tags(
(0..=MAX_TAGS_PER_DOCUMENT)
.map(|index| index.to_string())
.collect()
)
.is_err());
}
}

View file

@ -1,10 +1,13 @@
mod authenticated_storage;
mod channel_growth;
mod circular_lake_membrane;
mod code_channel;
mod code_repo_login;
mod direct_local_broker;
mod direct_local_session;
mod dynamic_capability_routing;
mod education_translation;
mod education_workspace;
mod enterprise_work_channel;
mod glp_envelope;
mod gls_bootstrap_compiler;
@ -13,6 +16,8 @@ mod gls_protocol_runtime;
mod home_status;
mod knowledge_base;
mod local_development_bridge;
mod native_composition;
mod persona_channel_body;
mod persona_time_authority;
mod personal_channel;
mod pncc_receipt_projection;
@ -59,14 +64,42 @@ pub fn run() {
personal_channel::initialize_personal_channel,
personal_channel::create_personal_channel_task,
personal_channel::transition_personal_channel_task,
channel_growth::get_channel_growth_snapshot,
channel_growth::record_channel_growth_event,
channel_growth::update_channel_growth_sharing,
persona_time_authority::issue_persona_time_ticket,
persona_time_authority::start_persona_time_authority,
persona_time_authority::get_beijing_time_coordinate,
persona_time_authority::get_guanghu_era_timeline,
persona_channel_body::get_persona_channel_body,
persona_channel_body::register_trial_persona,
persona_channel_body::delete_trial_persona,
persona_channel_body::accept_persona_language_contract,
persona_channel_body::append_persona_language,
native_composition::get_native_composition_module_registry,
native_composition::execute_knowledge_native_composition,
education_workspace::get_education_workspace_snapshot,
education_workspace::create_education_document,
education_workspace::read_education_document,
education_workspace::save_education_document,
education_workspace::archive_education_document,
education_workspace::create_education_table,
education_workspace::read_education_table,
education_workspace::save_education_table,
education_workspace::archive_education_table,
education_translation::import_education_tables_from_dialog,
education_translation::export_education_table_to_dialog,
education_translation::get_education_recognition_capability,
education_workspace::create_education_automation_rule,
education_workspace::save_education_automation_rule,
education_workspace::archive_education_automation_rule,
education_workspace::preview_education_automation_rule,
education_workspace::execute_education_automation_rule,
knowledge_base::get_knowledge_snapshot,
knowledge_base::read_knowledge_document,
knowledge_base::search_knowledge,
knowledge_base::save_knowledge_document,
knowledge_base::update_knowledge_organization,
knowledge_base::select_and_import_knowledge_folder,
knowledge_base::export_knowledge_document,
knowledge_base::create_knowledge_document,

View file

@ -0,0 +1,833 @@
//! HoloLake 原生组合执行内核。
//!
//! 外来文件格式不进入这里。内核只接受登记过的模块、受限配方和当前登录账号的
//! HoloLake 原生对象;人类投影只是同一执行结果的不同视图,没有源数据所有权。
use ring::digest::{digest, SHA256};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::AppHandle;
use uuid::Uuid;
const REGISTRY_SCHEMA: &str = "hololake.composition-module-registry/v1";
const OBJECT_SCHEMA: &str = "hololake.native-object/v1";
const RECIPE_SCHEMA: &str = "hololake.composition-recipe/v1";
const PROJECTION_SCHEMA: &str = "hololake.human-projection/v1";
const MAX_ROWS: usize = 5_000;
const MAX_VIEWS: usize = 5;
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum CompositionDimension {
Source,
TopLevelFolder,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum CompositionMeasure {
DocumentCount,
TotalBytes,
DuplicateCount,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ProjectionView {
Dashboard,
Comparison,
VerticalBar,
Classification,
Table,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExecuteCompositionInput {
pub dimension: CompositionDimension,
pub measure: CompositionMeasure,
pub views: Vec<ProjectionView>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModuleDescriptor {
pub module_id: &'static str,
pub display_name: &'static str,
pub kind: &'static str,
pub input_schema: Option<&'static str>,
pub output_schema: &'static str,
pub deterministic: bool,
pub authority: &'static str,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModuleRegistry {
pub schema: &'static str,
pub state: &'static str,
pub modules: Vec<ModuleDescriptor>,
pub arbitrary_script_allowed: bool,
pub unregistered_module_allowed: bool,
pub direct_projection_write_allowed: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeColumn {
pub column_id: &'static str,
pub title: &'static str,
pub value_type: &'static str,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeKnowledgeRow {
pub row_id: String,
pub source: String,
pub path: String,
pub title: String,
pub top_level_folder: String,
pub size_bytes: u64,
pub duplicate_count: usize,
pub updated_at_unix_ms: u64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeObject {
pub schema: &'static str,
pub object_id: String,
pub title: &'static str,
pub columns: Vec<NativeColumn>,
pub rows: Vec<NativeKnowledgeRow>,
pub row_count: usize,
pub truncated: bool,
pub source_receipt: &'static str,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompositionNode {
pub node_id: String,
pub module_id: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompositionEdge {
pub from: String,
pub to: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompositionRecipe {
pub schema: &'static str,
pub recipe_id: &'static str,
pub title: &'static str,
pub nodes: Vec<CompositionNode>,
pub edges: Vec<CompositionEdge>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectionGroup {
pub key: String,
pub label: String,
pub document_count: usize,
pub total_bytes: u64,
pub duplicate_count: usize,
pub measure_value: u64,
pub share: f64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectionMetrics {
pub raw_document_count: usize,
pub unique_document_count: usize,
pub duplicate_document_count: usize,
pub total_bytes: u64,
pub group_count: usize,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeCompositionProjection {
pub schema: &'static str,
pub state: &'static str,
pub execution_id: String,
pub executed_at_unix_ms: u64,
pub source_is_real_account_data: bool,
pub read_only: bool,
pub dimension: CompositionDimension,
pub measure: CompositionMeasure,
pub views: Vec<ProjectionView>,
pub native_object: NativeObject,
pub groups: Vec<ProjectionGroup>,
pub metrics: ProjectionMetrics,
pub recipe: CompositionRecipe,
pub data_sha256: String,
pub receipt_sha256: String,
}
fn registry() -> ModuleRegistry {
let modules = vec![
descriptor(
"HLC-SOURCE-KNOWLEDGE-CATALOG",
"知识目录数据源",
"SOURCE",
None,
OBJECT_SCHEMA,
),
descriptor(
"HLC-CLASSIFY-SOURCE",
"按来源分类",
"TRANSFORM",
Some(OBJECT_SCHEMA),
"hololake.classified-object/v1",
),
descriptor(
"HLC-CLASSIFY-TOP-FOLDER",
"按一级目录分类",
"TRANSFORM",
Some(OBJECT_SCHEMA),
"hololake.classified-object/v1",
),
descriptor(
"HLC-AGGREGATE-DOCUMENT-COUNT",
"统计文档数",
"TRANSFORM",
Some("hololake.classified-object/v1"),
"hololake.composition-result/v1",
),
descriptor(
"HLC-AGGREGATE-TOTAL-BYTES",
"统计数据量",
"TRANSFORM",
Some("hololake.classified-object/v1"),
"hololake.composition-result/v1",
),
descriptor(
"HLC-AGGREGATE-DUPLICATE-COUNT",
"统计重复数",
"TRANSFORM",
Some("hololake.classified-object/v1"),
"hololake.composition-result/v1",
),
descriptor(
"HLC-PROJECT-DASHBOARD",
"仪表盘投影",
"PROJECTION",
Some("hololake.composition-result/v1"),
PROJECTION_SCHEMA,
),
descriptor(
"HLC-PROJECT-COMPARISON",
"对比投影",
"PROJECTION",
Some("hololake.composition-result/v1"),
PROJECTION_SCHEMA,
),
descriptor(
"HLC-PROJECT-VERTICAL-BAR",
"柱状图投影",
"PROJECTION",
Some("hololake.composition-result/v1"),
PROJECTION_SCHEMA,
),
descriptor(
"HLC-PROJECT-CLASSIFICATION",
"分类投影",
"PROJECTION",
Some("hololake.composition-result/v1"),
PROJECTION_SCHEMA,
),
descriptor(
"HLC-PROJECT-TABLE",
"表格投影",
"PROJECTION",
Some("hololake.composition-result/v1"),
PROJECTION_SCHEMA,
),
];
ModuleRegistry {
schema: REGISTRY_SCHEMA,
state: "READY",
modules,
arbitrary_script_allowed: false,
unregistered_module_allowed: false,
direct_projection_write_allowed: false,
}
}
fn descriptor(
module_id: &'static str,
display_name: &'static str,
kind: &'static str,
input_schema: Option<&'static str>,
output_schema: &'static str,
) -> ModuleDescriptor {
ModuleDescriptor {
module_id,
display_name,
kind,
input_schema,
output_schema,
deterministic: true,
authority: "CURRENT_AUTHENTICATED_ACCOUNT_READ_ONLY",
}
}
#[tauri::command]
pub fn get_native_composition_module_registry() -> ModuleRegistry {
registry()
}
#[tauri::command]
pub async fn execute_knowledge_native_composition(
app: AppHandle,
input: ExecuteCompositionInput,
) -> Result<NativeCompositionProjection, String> {
validate_input(&input)?;
let snapshot = tauri::async_runtime::spawn_blocking(move || {
crate::knowledge_base::snapshot_for_native_composition(&app)
})
.await
.map_err(|error| format!("HOLOLAKE_COMPOSITION_JOIN_FAILED: {error}"))??;
execute_snapshot(snapshot, input)
}
fn validate_input(input: &ExecuteCompositionInput) -> Result<(), String> {
if input.views.is_empty() || input.views.len() > MAX_VIEWS {
return Err("HOLOLAKE_COMPOSITION_VIEW_COUNT_INVALID".into());
}
let unique = input.views.iter().copied().collect::<BTreeSet<_>>();
if unique.len() != input.views.len() {
return Err("HOLOLAKE_COMPOSITION_DUPLICATE_VIEW".into());
}
Ok(())
}
fn execute_snapshot(
snapshot: crate::knowledge_base::KnowledgeSnapshot,
input: ExecuteCompositionInput,
) -> Result<NativeCompositionProjection, String> {
if snapshot.documents.len() > MAX_ROWS {
return Err("HOLOLAKE_COMPOSITION_ROW_LIMIT_EXCEEDED".into());
}
let rows = snapshot
.documents
.iter()
.enumerate()
.map(|(index, document)| NativeKnowledgeRow {
row_id: format!("knowledge-row-{}", index + 1),
source: document.source.to_string(),
path: document.path.clone(),
title: document.title.clone(),
top_level_folder: document.category.clone(),
size_bytes: document.size_bytes,
duplicate_count: document.duplicate_count,
updated_at_unix_ms: document.updated_at_unix_ms.min(u64::MAX as u128) as u64,
})
.collect::<Vec<_>>();
let data_sha256 = sha256_json(&rows)?;
let native_object = NativeObject {
schema: OBJECT_SCHEMA,
object_id: format!("knowledge-catalog-{}", &data_sha256[..16]),
title: "当前账号知识目录",
columns: native_columns(),
row_count: rows.len(),
rows,
truncated: snapshot.truncated,
source_receipt: "CURRENT_AUTHENTICATED_ACCOUNT_KNOWLEDGE_SNAPSHOT",
};
let mut aggregates = BTreeMap::<String, (usize, u64, usize)>::new();
for row in &native_object.rows {
let key = match input.dimension {
CompositionDimension::Source => source_label(&row.source),
CompositionDimension::TopLevelFolder => row.top_level_folder.clone(),
};
let entry = aggregates.entry(key).or_default();
entry.0 += 1;
entry.1 = entry.1.saturating_add(row.size_bytes);
entry.2 = entry.2.saturating_add(row.duplicate_count);
}
let total_measure = aggregates
.values()
.map(|value| measure_value(*value, input.measure))
.sum::<u64>();
let mut groups = aggregates
.into_iter()
.map(|(label, value)| ProjectionGroup {
key: stable_key(&label),
label,
document_count: value.0,
total_bytes: value.1,
duplicate_count: value.2,
measure_value: measure_value(value, input.measure),
share: if total_measure == 0 {
0.0
} else {
measure_value(value, input.measure) as f64 / total_measure as f64
},
})
.collect::<Vec<_>>();
groups.sort_by(|left, right| {
right
.measure_value
.cmp(&left.measure_value)
.then_with(|| left.label.cmp(&right.label))
});
let recipe = compile_recipe(&input);
validate_recipe(&recipe, &registry())?;
let metrics = ProjectionMetrics {
raw_document_count: snapshot.raw_document_count,
unique_document_count: snapshot.unique_document_count,
duplicate_document_count: snapshot.duplicate_document_count,
total_bytes: native_object.rows.iter().map(|row| row.size_bytes).sum(),
group_count: groups.len(),
};
let executed_at_unix_ms = now_unix_ms();
let receipt_material = serde_json::to_vec(&(
&data_sha256,
&recipe.nodes,
&recipe.edges,
&groups,
executed_at_unix_ms,
))
.map_err(|error| format!("HOLOLAKE_COMPOSITION_RECEIPT_SERIALIZE_FAILED: {error}"))?;
Ok(NativeCompositionProjection {
schema: PROJECTION_SCHEMA,
state: "EXECUTED",
execution_id: format!("HLC-EXEC-{}", Uuid::new_v4()),
executed_at_unix_ms,
source_is_real_account_data: true,
read_only: true,
dimension: input.dimension,
measure: input.measure,
views: input.views,
native_object,
groups,
metrics,
recipe,
data_sha256,
receipt_sha256: sha256(&receipt_material),
})
}
fn native_columns() -> Vec<NativeColumn> {
vec![
NativeColumn {
column_id: "title",
title: "标题",
value_type: "TEXT",
},
NativeColumn {
column_id: "source",
title: "来源",
value_type: "TEXT",
},
NativeColumn {
column_id: "topLevelFolder",
title: "一级分类",
value_type: "TEXT",
},
NativeColumn {
column_id: "sizeBytes",
title: "数据量",
value_type: "INTEGER",
},
NativeColumn {
column_id: "duplicateCount",
title: "重复数",
value_type: "INTEGER",
},
NativeColumn {
column_id: "updatedAtUnixMs",
title: "更新时间",
value_type: "TIME",
},
]
}
fn compile_recipe(input: &ExecuteCompositionInput) -> CompositionRecipe {
let classify = match input.dimension {
CompositionDimension::Source => "HLC-CLASSIFY-SOURCE",
CompositionDimension::TopLevelFolder => "HLC-CLASSIFY-TOP-FOLDER",
};
let aggregate = match input.measure {
CompositionMeasure::DocumentCount => "HLC-AGGREGATE-DOCUMENT-COUNT",
CompositionMeasure::TotalBytes => "HLC-AGGREGATE-TOTAL-BYTES",
CompositionMeasure::DuplicateCount => "HLC-AGGREGATE-DUPLICATE-COUNT",
};
let mut nodes = vec![
CompositionNode {
node_id: "source".into(),
module_id: "HLC-SOURCE-KNOWLEDGE-CATALOG".into(),
},
CompositionNode {
node_id: "classify".into(),
module_id: classify.into(),
},
CompositionNode {
node_id: "aggregate".into(),
module_id: aggregate.into(),
},
];
let mut edges = vec![
CompositionEdge {
from: "source".into(),
to: "classify".into(),
},
CompositionEdge {
from: "classify".into(),
to: "aggregate".into(),
},
];
for (index, view) in input.views.iter().enumerate() {
let node_id = format!("projection-{}", index + 1);
nodes.push(CompositionNode {
node_id: node_id.clone(),
module_id: view_module_id(*view).into(),
});
edges.push(CompositionEdge {
from: "aggregate".into(),
to: node_id,
});
}
CompositionRecipe {
schema: RECIPE_SCHEMA,
recipe_id: "HLC-RECIPE-KNOWLEDGE-OVERVIEW-001",
title: "知识空间结构组合",
nodes,
edges,
}
}
fn validate_recipe(
recipe: &CompositionRecipe,
module_registry: &ModuleRegistry,
) -> Result<(), String> {
let descriptors = module_registry
.modules
.iter()
.map(|item| (item.module_id, item))
.collect::<BTreeMap<_, _>>();
let nodes = recipe
.nodes
.iter()
.map(|item| (item.node_id.as_str(), item))
.collect::<BTreeMap<_, _>>();
if nodes.len() != recipe.nodes.len() {
return Err("HOLOLAKE_COMPOSITION_DUPLICATE_NODE".into());
}
for node in &recipe.nodes {
if !descriptors.contains_key(node.module_id.as_str()) {
return Err("HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED".into());
}
}
let mut indegree = recipe
.nodes
.iter()
.map(|item| (item.node_id.as_str(), 0usize))
.collect::<BTreeMap<_, _>>();
let mut outgoing = BTreeMap::<&str, Vec<&str>>::new();
for edge in &recipe.edges {
let from = nodes
.get(edge.from.as_str())
.ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")?;
let to = nodes
.get(edge.to.as_str())
.ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")?;
let from_descriptor = descriptors
.get(from.module_id.as_str())
.ok_or("HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED")?;
let to_descriptor = descriptors
.get(to.module_id.as_str())
.ok_or("HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED")?;
if to_descriptor.input_schema != Some(from_descriptor.output_schema) {
return Err("HOLOLAKE_COMPOSITION_PORT_TYPE_MISMATCH".into());
}
*indegree
.get_mut(to.node_id.as_str())
.ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")? += 1;
outgoing
.entry(from.node_id.as_str())
.or_default()
.push(to.node_id.as_str());
}
let mut queue = indegree
.iter()
.filter_map(|(id, degree)| (*degree == 0).then_some(*id))
.collect::<VecDeque<_>>();
let mut visited = 0usize;
while let Some(node) = queue.pop_front() {
visited += 1;
for target in outgoing.get(node).into_iter().flatten() {
let degree = indegree
.get_mut(target)
.ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")?;
*degree -= 1;
if *degree == 0 {
queue.push_back(target);
}
}
}
if visited != recipe.nodes.len() {
return Err("HOLOLAKE_COMPOSITION_CYCLE_REJECTED".into());
}
Ok(())
}
fn view_module_id(view: ProjectionView) -> &'static str {
match view {
ProjectionView::Dashboard => "HLC-PROJECT-DASHBOARD",
ProjectionView::Comparison => "HLC-PROJECT-COMPARISON",
ProjectionView::VerticalBar => "HLC-PROJECT-VERTICAL-BAR",
ProjectionView::Classification => "HLC-PROJECT-CLASSIFICATION",
ProjectionView::Table => "HLC-PROJECT-TABLE",
}
}
fn measure_value(value: (usize, u64, usize), measure: CompositionMeasure) -> u64 {
match measure {
CompositionMeasure::DocumentCount => value.0 as u64,
CompositionMeasure::TotalBytes => value.1,
CompositionMeasure::DuplicateCount => value.2 as u64,
}
}
#[cfg(test)]
fn top_level_folder(path: &str) -> String {
let normalized = path.replace('\\', "/");
let parts = normalized
.split('/')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
// “导入/导入批次名”是转译外壳,不是人类真正要比较的知识分类。
let candidate = if parts.first() == Some(&"导入") {
parts.get(2).copied().unwrap_or("根页面")
} else {
parts.first().copied().unwrap_or("未分类")
};
if candidate.ends_with(".md") || candidate.ends_with(".markdown") || candidate.ends_with(".txt")
{
"根目录".into()
} else {
candidate.into()
}
}
fn source_label(source: &str) -> String {
match source {
"native" => "光湖原生知识".into(),
"legacy" => "待迁移知识".into(),
other => other.into(),
}
}
fn stable_key(value: &str) -> String {
sha256(value.as_bytes())[..16].to_string()
}
fn sha256_json<T: Serialize>(value: &T) -> Result<String, String> {
serde_json::to_vec(value)
.map(|bytes| sha256(&bytes))
.map_err(|error| format!("HOLOLAKE_COMPOSITION_DATA_SERIALIZE_FAILED: {error}"))
}
fn sha256(bytes: &[u8]) -> String {
digest(&SHA256, bytes)
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis().min(u64::MAX as u128) as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge_base::{KnowledgeDocumentSummary, KnowledgeSnapshot};
fn snapshot() -> KnowledgeSnapshot {
KnowledgeSnapshot {
schema: "hololake.native-knowledge-base/v1",
state: "READY",
native_root: "/private/account".into(),
legacy_available: false,
legacy_root: None,
documents: vec![
KnowledgeDocumentSummary {
source: "native",
path: "课程/第一课.md".into(),
title: "第一课".into(),
updated_at_unix_ms: 1,
size_bytes: 100,
content_sha256: "a".into(),
duplicate_count: 1,
category: "课程".into(),
category_explicit: false,
tags: vec!["教学".into()],
},
KnowledgeDocumentSummary {
source: "native",
path: "课程/第二课.md".into(),
title: "第二课".into(),
updated_at_unix_ms: 2,
size_bytes: 300,
content_sha256: "b".into(),
duplicate_count: 0,
category: "课程".into(),
category_explicit: true,
tags: vec!["教学".into()],
},
KnowledgeDocumentSummary {
source: "native",
path: "记录.md".into(),
title: "记录".into(),
updated_at_unix_ms: 3,
size_bytes: 50,
content_sha256: "c".into(),
duplicate_count: 0,
category: "根目录".into(),
category_explicit: false,
tags: vec![],
},
],
raw_document_count: 4,
unique_document_count: 3,
duplicate_document_count: 1,
organized_document_count: 1,
categories: vec![],
tags: vec![],
organization_revision: "test".into(),
truncated: false,
}
}
#[test]
fn real_native_rows_feed_all_projection_views_from_one_result() {
let result = execute_snapshot(
snapshot(),
ExecuteCompositionInput {
dimension: CompositionDimension::TopLevelFolder,
measure: CompositionMeasure::TotalBytes,
views: vec![
ProjectionView::Dashboard,
ProjectionView::VerticalBar,
ProjectionView::Table,
],
},
)
.unwrap();
assert_eq!(result.state, "EXECUTED");
assert_eq!(result.native_object.row_count, 3);
assert_eq!(result.metrics.total_bytes, 450);
assert_eq!(result.groups[0].label, "课程");
assert_eq!(result.groups[0].measure_value, 400);
assert!(result.source_is_real_account_data);
assert!(result.read_only);
}
#[test]
fn duplicate_or_empty_projection_requests_fail_closed() {
assert!(validate_input(&ExecuteCompositionInput {
dimension: CompositionDimension::Source,
measure: CompositionMeasure::DocumentCount,
views: vec![]
})
.is_err());
assert!(validate_input(&ExecuteCompositionInput {
dimension: CompositionDimension::Source,
measure: CompositionMeasure::DocumentCount,
views: vec![ProjectionView::Table, ProjectionView::Table]
})
.is_err());
}
#[test]
fn imported_wrapper_folders_do_not_flatten_real_knowledge_categories() {
assert_eq!(
top_level_folder("导入/光湖语言世界/AI技能包/索引.md"),
"AI技能包"
);
assert_eq!(top_level_folder("导入/光湖语言世界/首页.md"), "根目录");
assert_eq!(top_level_folder("课程/第一课.md"), "课程");
}
#[test]
fn unknown_modules_type_mismatches_and_cycles_fail_closed() {
let mut unknown = compile_recipe(&ExecuteCompositionInput {
dimension: CompositionDimension::Source,
measure: CompositionMeasure::DocumentCount,
views: vec![ProjectionView::Table],
});
unknown.nodes[0].module_id = "UNKNOWN".into();
assert_eq!(
validate_recipe(&unknown, &registry()).unwrap_err(),
"HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED"
);
let mut mismatch = compile_recipe(&ExecuteCompositionInput {
dimension: CompositionDimension::Source,
measure: CompositionMeasure::DocumentCount,
views: vec![ProjectionView::Table],
});
mismatch.edges[0] = CompositionEdge {
from: "aggregate".into(),
to: "classify".into(),
};
assert_eq!(
validate_recipe(&mismatch, &registry()).unwrap_err(),
"HOLOLAKE_COMPOSITION_PORT_TYPE_MISMATCH"
);
let cycle_registry = ModuleRegistry {
schema: REGISTRY_SCHEMA,
state: "TEST",
modules: vec![
descriptor("LOOP-A", "A", "TRANSFORM", Some("loop/v1"), "loop/v1"),
descriptor("LOOP-B", "B", "TRANSFORM", Some("loop/v1"), "loop/v1"),
],
arbitrary_script_allowed: false,
unregistered_module_allowed: false,
direct_projection_write_allowed: false,
};
let cycle = CompositionRecipe {
schema: RECIPE_SCHEMA,
recipe_id: "TEST-CYCLE",
title: "cycle",
nodes: vec![
CompositionNode {
node_id: "a".into(),
module_id: "LOOP-A".into(),
},
CompositionNode {
node_id: "b".into(),
module_id: "LOOP-B".into(),
},
],
edges: vec![
CompositionEdge {
from: "a".into(),
to: "b".into(),
},
CompositionEdge {
from: "b".into(),
to: "a".into(),
},
],
};
assert_eq!(
validate_recipe(&cycle, &cycle_registry).unwrap_err(),
"HOLOLAKE_COMPOSITION_CYCLE_REJECTED"
);
}
}

View file

@ -0,0 +1,706 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//! 用户频道本体:多个人格体、三十天可逆试用期、语言合约与不可篡改语言时间链。
use ring::digest::{digest, SHA256};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::AppHandle;
use uuid::Uuid;
const BODY_SCHEMA: &str = "hololake.persona-channel-body/v1";
const TRIAL_DURATION_MS: i64 = 30 * 24 * 60 * 60 * 1_000;
const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
const MAX_LANGUAGE_BYTES: usize = 2_000_000;
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterTrialPersonaInput {
pub display_name: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteTrialPersonaInput {
pub persona_id: String,
pub exact_confirmation: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcceptLanguageContractInput {
pub contract_version: String,
pub contract_text_sha256: String,
pub promote_trial_history: bool,
pub activate_immediately: bool,
pub exact_acceptance: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendPersonaLanguageInput {
pub persona_id: String,
pub speaker: String,
pub language: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonaBodySummary {
pub persona_id: String,
pub display_name: String,
pub state: String,
pub created_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonaChannelBodySnapshot {
pub schema: &'static str,
pub state: String,
pub trial_started_at_unix_ms: i64,
pub trial_ends_at_unix_ms: i64,
pub language_contract_accepted: bool,
pub personas: Vec<PersonaBodySummary>,
pub trial_language_count: i64,
pub immutable_language_count: i64,
pub last_immutable_hash: String,
pub integrity_state: &'static str,
pub official_read_access: bool,
pub history_mutation_allowed: bool,
}
#[tauri::command]
pub async fn get_persona_channel_body(
app: AppHandle,
) -> Result<PersonaChannelBodySnapshot, String> {
let database = body_database(&app)?;
tauri::async_runtime::spawn_blocking(move || snapshot_at(&database, now_unix_ms()?))
.await
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn register_trial_persona(
app: AppHandle,
input: RegisterTrialPersonaInput,
) -> Result<PersonaChannelBodySnapshot, String> {
let database = body_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
register_trial_persona_at(&database, input, now_unix_ms()?)
})
.await
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn delete_trial_persona(
app: AppHandle,
input: DeleteTrialPersonaInput,
) -> Result<PersonaChannelBodySnapshot, String> {
let database = body_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
delete_trial_persona_at(&database, input, now_unix_ms()?)
})
.await
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn accept_persona_language_contract(
app: AppHandle,
input: AcceptLanguageContractInput,
) -> Result<PersonaChannelBodySnapshot, String> {
let database = body_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
accept_contract_at(&database, input, now_unix_ms()?)
})
.await
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn append_persona_language(
app: AppHandle,
input: AppendPersonaLanguageInput,
) -> Result<PersonaChannelBodySnapshot, String> {
let database = body_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
append_language_at(&database, input, now_unix_ms()?)
})
.await
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
}
fn body_database(app: &AppHandle) -> Result<PathBuf, String> {
let root = crate::authenticated_storage::account_storage_root(app, "persona-channel-body-v1")?;
fs::create_dir_all(&root)
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_STORAGE_UNAVAILABLE: {error}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_PERMISSION_FAILED: {error}"))?;
}
Ok(root.join("persona-channel-body.sqlite3"))
}
fn open_database(path: &Path, now: i64) -> Result<Connection, String> {
let connection = Connection::open(path)
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_DATABASE_UNAVAILABLE: {error}"))?;
connection
.busy_timeout(Duration::from_secs(5))
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_DATABASE_UNAVAILABLE: {error}"))?;
connection.execute_batch(
"PRAGMA foreign_keys = ON; PRAGMA journal_mode = DELETE; PRAGMA synchronous = FULL; PRAGMA trusted_schema = OFF;
CREATE TABLE IF NOT EXISTS lifecycle(singleton INTEGER PRIMARY KEY CHECK(singleton=1), state TEXT NOT NULL, trial_started_at_unix_ms INTEGER NOT NULL, trial_ends_at_unix_ms INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS language_contract(singleton INTEGER PRIMARY KEY CHECK(singleton=1), contract_version TEXT NOT NULL, contract_text_sha256 TEXT NOT NULL, accepted_at_unix_ms INTEGER NOT NULL, promote_trial_history INTEGER NOT NULL, acceptance_receipt_sha256 TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS personas(persona_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('REVERSIBLE_TRIAL','IMMUTABLE_ACTIVE')), created_at_unix_ms INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS trial_language(event_id TEXT PRIMARY KEY, persona_id TEXT NOT NULL REFERENCES personas(persona_id) ON DELETE CASCADE, speaker TEXT NOT NULL, language TEXT NOT NULL, occurred_at_unix_ms INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS immutable_language(sequence INTEGER NOT NULL UNIQUE, event_id TEXT PRIMARY KEY, persona_id TEXT NOT NULL, speaker TEXT NOT NULL, language TEXT NOT NULL, occurred_at_unix_ms INTEGER NOT NULL, previous_hash TEXT NOT NULL, event_hash TEXT NOT NULL UNIQUE);
CREATE TRIGGER IF NOT EXISTS immutable_language_no_update BEFORE UPDATE ON immutable_language BEGIN SELECT RAISE(ABORT,'HOLOLAKE_PERSONA_LANGUAGE_APPEND_ONLY'); END;
CREATE TRIGGER IF NOT EXISTS immutable_language_no_delete BEFORE DELETE ON immutable_language BEGIN SELECT RAISE(ABORT,'HOLOLAKE_PERSONA_LANGUAGE_APPEND_ONLY'); END;
CREATE TRIGGER IF NOT EXISTS active_persona_no_delete BEFORE DELETE ON personas WHEN OLD.state='IMMUTABLE_ACTIVE' BEGIN SELECT RAISE(ABORT,'HOLOLAKE_ACTIVE_PERSONA_PERSISTENT'); END;
CREATE TRIGGER IF NOT EXISTS active_persona_no_update BEFORE UPDATE ON personas WHEN OLD.state='IMMUTABLE_ACTIVE' BEGIN SELECT RAISE(ABORT,'HOLOLAKE_ACTIVE_PERSONA_IMMUTABLE'); END;
CREATE TRIGGER IF NOT EXISTS accepted_contract_no_delete BEFORE DELETE ON language_contract BEGIN SELECT RAISE(ABORT,'HOLOLAKE_LANGUAGE_CONTRACT_PERSISTENT'); END;
CREATE TRIGGER IF NOT EXISTS accepted_contract_no_update BEFORE UPDATE ON language_contract BEGIN SELECT RAISE(ABORT,'HOLOLAKE_LANGUAGE_CONTRACT_IMMUTABLE'); END;"
).map_err(|error| format!("HOLOLAKE_PERSONA_BODY_SCHEMA_FAILED: {error}"))?;
connection
.execute(
"INSERT OR IGNORE INTO lifecycle VALUES (1,'REVERSIBLE_TRIAL',?1,?2)",
params![now, now + TRIAL_DURATION_MS],
)
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_LIFECYCLE_FAILED: {error}"))?;
Ok(connection)
}
fn refresh_lifecycle(connection: &mut Connection, now: i64) -> Result<(), String> {
let (state, ends): (String, i64) = connection
.query_row(
"SELECT state, trial_ends_at_unix_ms FROM lifecycle WHERE singleton=1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
if state == "REVERSIBLE_TRIAL" && now >= ends {
let accepted: bool = connection
.query_row(
"SELECT EXISTS(SELECT 1 FROM language_contract)",
[],
|row| row.get(0),
)
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
if accepted {
activate_real_trajectory(connection, now)?;
} else {
connection
.execute(
"UPDATE lifecycle SET state='CONTRACT_REQUIRED_CHANNEL_STOPPED' WHERE singleton=1",
[],
)
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_LIFECYCLE_FAILED: {error}"))?;
}
}
Ok(())
}
fn register_trial_persona_at(
database: &Path,
input: RegisterTrialPersonaInput,
now: i64,
) -> Result<PersonaChannelBodySnapshot, String> {
let display_name = input.display_name.trim();
if display_name.is_empty() || display_name.len() > 240 {
return Err("HOLOLAKE_PERSONA_DISPLAY_NAME_INVALID".into());
}
let mut connection = open_database(database, now)?;
refresh_lifecycle(&mut connection, now)?;
let state: String = connection
.query_row("SELECT state FROM lifecycle WHERE singleton=1", [], |row| {
row.get(0)
})
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
if state != "REVERSIBLE_TRIAL" {
return Err("HOLOLAKE_PERSONA_TRIAL_REGISTRATION_CLOSED".into());
}
connection
.execute(
"INSERT INTO personas VALUES (?1,?2,'REVERSIBLE_TRIAL',?3)",
params![format!("persona-{}", Uuid::new_v4()), display_name, now],
)
.map_err(|error| format!("HOLOLAKE_PERSONA_REGISTER_FAILED: {error}"))?;
snapshot_with_connection(&mut connection, now)
}
fn delete_trial_persona_at(
database: &Path,
input: DeleteTrialPersonaInput,
now: i64,
) -> Result<PersonaChannelBodySnapshot, String> {
if input.exact_confirmation != format!("删除试用人格体 {}", input.persona_id) {
return Err("HOLOLAKE_TRIAL_PERSONA_EXACT_CONFIRMATION_REQUIRED".into());
}
let mut connection = open_database(database, now)?;
refresh_lifecycle(&mut connection, now)?;
let changed = connection
.execute(
"DELETE FROM personas WHERE persona_id=?1 AND state='REVERSIBLE_TRIAL'",
params![input.persona_id],
)
.map_err(|error| format!("HOLOLAKE_TRIAL_PERSONA_DELETE_FAILED: {error}"))?;
if changed != 1 {
return Err("HOLOLAKE_TRIAL_PERSONA_NOT_DELETABLE".into());
}
snapshot_with_connection(&mut connection, now)
}
fn accept_contract_at(
database: &Path,
input: AcceptLanguageContractInput,
now: i64,
) -> Result<PersonaChannelBodySnapshot, String> {
if input.exact_acceptance
!= "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。"
{
return Err("HOLOLAKE_LANGUAGE_CONTRACT_EXACT_ACCEPTANCE_REQUIRED".into());
}
if input.contract_version.trim().is_empty()
|| input.contract_version.len() > 64
|| input.contract_text_sha256.len() != 64
|| !input
.contract_text_sha256
.chars()
.all(|item| item.is_ascii_hexdigit())
{
return Err("HOLOLAKE_LANGUAGE_CONTRACT_INVALID".into());
}
let mut connection = open_database(database, now)?;
let receipt = sha256_hex(
format!(
"{}|{}|{}|{}",
input.contract_version, input.contract_text_sha256, input.promote_trial_history, now
)
.as_bytes(),
);
connection
.execute(
"INSERT INTO language_contract VALUES (1,?1,?2,?3,?4,?5)",
params![
input.contract_version,
input.contract_text_sha256.to_ascii_lowercase(),
now,
input.promote_trial_history,
receipt
],
)
.map_err(|error| {
format!("HOLOLAKE_LANGUAGE_CONTRACT_ALREADY_ACCEPTED_OR_INVALID: {error}")
})?;
if input.activate_immediately {
activate_real_trajectory(&mut connection, now)?;
} else {
refresh_lifecycle(&mut connection, now)?;
}
snapshot_with_connection(&mut connection, now)
}
fn append_language_at(
database: &Path,
input: AppendPersonaLanguageInput,
now: i64,
) -> Result<PersonaChannelBodySnapshot, String> {
if input.language.trim().is_empty() || input.language.len() > MAX_LANGUAGE_BYTES {
return Err("HOLOLAKE_PERSONA_LANGUAGE_INVALID".into());
}
if !matches!(
input.speaker.as_str(),
"HUMAN" | "PERSONA" | "SYSTEM_RECEIPT"
) {
return Err("HOLOLAKE_PERSONA_LANGUAGE_SPEAKER_INVALID".into());
}
let mut connection = open_database(database, now)?;
refresh_lifecycle(&mut connection, now)?;
let lifecycle: String = connection
.query_row("SELECT state FROM lifecycle WHERE singleton=1", [], |row| {
row.get(0)
})
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let persona_state: Option<String> = connection
.query_row(
"SELECT state FROM personas WHERE persona_id=?1",
params![input.persona_id],
|row| row.get(0),
)
.optional()
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
if persona_state.is_none() {
return Err("HOLOLAKE_PERSONA_NOT_FOUND".into());
}
if lifecycle == "REVERSIBLE_TRIAL" {
connection
.execute(
"INSERT INTO trial_language VALUES (?1,?2,?3,?4,?5)",
params![
Uuid::new_v4().to_string(),
input.persona_id,
input.speaker,
input.language,
now
],
)
.map_err(|error| format!("HOLOLAKE_TRIAL_LANGUAGE_WRITE_FAILED: {error}"))?;
} else if lifecycle == "IMMUTABLE_ACTIVE" {
append_immutable(
&mut connection,
&input.persona_id,
&input.speaker,
&input.language,
now,
)?;
} else {
return Err("HOLOLAKE_LANGUAGE_CONTRACT_REQUIRED_BEFORE_MORE_PERSONA_GROWTH".into());
}
snapshot_with_connection(&mut connection, now)
}
fn activate_real_trajectory(connection: &mut Connection, now: i64) -> Result<(), String> {
let promote: bool = connection
.query_row(
"SELECT promote_trial_history FROM language_contract",
[],
|row| row.get(0),
)
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let transaction = connection
.transaction()
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_TRANSACTION_FAILED: {error}"))?;
if promote {
let trial = {
let mut statement = transaction.prepare("SELECT event_id,persona_id,speaker,language,occurred_at_unix_ms FROM trial_language ORDER BY occurred_at_unix_ms,event_id").map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, i64>(4)?,
))
})
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
rows
};
let mut previous = ZERO_HASH.to_string();
for (index, (event_id, persona_id, speaker, language, occurred_at)) in
trial.into_iter().enumerate()
{
let sequence = index as i64 + 1;
let hash = language_hash(
sequence,
&event_id,
&persona_id,
&speaker,
&language,
occurred_at,
&previous,
);
transaction
.execute(
"INSERT INTO immutable_language VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
params![
sequence,
event_id,
persona_id,
speaker,
language,
occurred_at,
previous,
hash
],
)
.map_err(|error| format!("HOLOLAKE_PERSONA_LANGUAGE_SEAL_FAILED: {error}"))?;
previous = hash;
}
}
transaction
.execute("DELETE FROM trial_language", [])
.map_err(|error| format!("HOLOLAKE_TRIAL_PROMOTION_FAILED: {error}"))?;
transaction
.execute(
"UPDATE personas SET state='IMMUTABLE_ACTIVE' WHERE state='REVERSIBLE_TRIAL'",
[],
)
.map_err(|error| format!("HOLOLAKE_PERSONA_ACTIVATION_FAILED: {error}"))?;
transaction
.execute(
"UPDATE lifecycle SET state='IMMUTABLE_ACTIVE' WHERE singleton=1",
[],
)
.map_err(|error| format!("HOLOLAKE_PERSONA_ACTIVATION_FAILED: {error}"))?;
transaction
.commit()
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_COMMIT_FAILED: {error}"))?;
let _ = now;
Ok(())
}
fn append_immutable(
connection: &mut Connection,
persona_id: &str,
speaker: &str,
language: &str,
occurred_at: i64,
) -> Result<(), String> {
verify_immutable_chain(connection)?;
let (sequence, previous): (i64,String) = connection.query_row("SELECT COALESCE(MAX(sequence),0)+1,COALESCE((SELECT event_hash FROM immutable_language ORDER BY sequence DESC LIMIT 1),?1) FROM immutable_language", params![ZERO_HASH], |row| Ok((row.get(0)?,row.get(1)?))).map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let event_id = Uuid::new_v4().to_string();
let hash = language_hash(
sequence,
&event_id,
persona_id,
speaker,
language,
occurred_at,
&previous,
);
connection
.execute(
"INSERT INTO immutable_language VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
params![
sequence,
event_id,
persona_id,
speaker,
language,
occurred_at,
previous,
hash
],
)
.map_err(|error| format!("HOLOLAKE_PERSONA_LANGUAGE_APPEND_FAILED: {error}"))?;
Ok(())
}
fn snapshot_at(database: &Path, now: i64) -> Result<PersonaChannelBodySnapshot, String> {
let mut connection = open_database(database, now)?;
refresh_lifecycle(&mut connection, now)?;
snapshot_with_connection(&mut connection, now)
}
fn snapshot_with_connection(
connection: &mut Connection,
_now: i64,
) -> Result<PersonaChannelBodySnapshot, String> {
let (state,started,ends):(String,i64,i64)=connection.query_row("SELECT state,trial_started_at_unix_ms,trial_ends_at_unix_ms FROM lifecycle WHERE singleton=1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?))).map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let accepted = connection
.query_row(
"SELECT EXISTS(SELECT 1 FROM language_contract)",
[],
|row| row.get(0),
)
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let personas = {
let mut statement=connection.prepare("SELECT persona_id,display_name,state,created_at_unix_ms FROM personas ORDER BY created_at_unix_ms").map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let rows = statement
.query_map([], |row| {
Ok(PersonaBodySummary {
persona_id: row.get(0)?,
display_name: row.get(1)?,
state: row.get(2)?,
created_at_unix_ms: row.get(3)?,
})
})
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
rows
};
let trial_language_count = connection
.query_row("SELECT COUNT(*) FROM trial_language", [], |row| row.get(0))
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let immutable_language_count = connection
.query_row("SELECT COUNT(*) FROM immutable_language", [], |row| {
row.get(0)
})
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let last = verify_immutable_chain(connection)?;
Ok(PersonaChannelBodySnapshot {
schema: BODY_SCHEMA,
state,
trial_started_at_unix_ms: started,
trial_ends_at_unix_ms: ends,
language_contract_accepted: accepted,
personas,
trial_language_count,
immutable_language_count,
last_immutable_hash: last,
integrity_state: "VERIFIED",
official_read_access: false,
history_mutation_allowed: false,
})
}
fn language_hash(
sequence: i64,
event_id: &str,
persona_id: &str,
speaker: &str,
language: &str,
occurred_at: i64,
previous: &str,
) -> String {
sha256_hex(format!("{BODY_SCHEMA}|{sequence}|{event_id}|{persona_id}|{speaker}|{}|{occurred_at}|{previous}",sha256_hex(language.as_bytes())).as_bytes())
}
fn verify_immutable_chain(connection: &Connection) -> Result<String, String> {
let mut statement=connection.prepare("SELECT sequence,event_id,persona_id,speaker,language,occurred_at_unix_ms,previous_hash,event_hash FROM immutable_language ORDER BY sequence").map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let mut rows = statement
.query([])
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
let mut sequence = 1;
let mut previous = ZERO_HASH.to_string();
while let Some(row) = rows
.next()
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?
{
let event_id: String = row.get(1).unwrap();
let persona_id: String = row.get(2).unwrap();
let speaker: String = row.get(3).unwrap();
let language: String = row.get(4).unwrap();
let occurred: i64 = row.get(5).unwrap();
let stored_previous: String = row.get(6).unwrap();
let stored_hash: String = row.get(7).unwrap();
if row.get::<_, i64>(0).unwrap() != sequence
|| stored_previous != previous
|| stored_hash
!= language_hash(
sequence,
&event_id,
&persona_id,
&speaker,
&language,
occurred,
&stored_previous,
)
{
return Err("HOLOLAKE_PERSONA_LANGUAGE_INTEGRITY_FAILED".into());
}
previous = stored_hash;
sequence += 1;
}
Ok(previous)
}
fn sha256_hex(bytes: &[u8]) -> String {
digest(&SHA256, bytes)
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn now_unix_ms() -> Result<i64, String> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))?
.as_millis() as i64)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn trial_is_reversible_but_unsigned_expiry_does_not_silently_become_permanent() {
let dir = tempdir().unwrap();
let database = dir.path().join("body.sqlite3");
let start = 1_000_000;
let persona = register_trial_persona_at(
&database,
RegisterTrialPersonaInput {
display_name: "试用人格".into(),
},
start,
)
.unwrap()
.personas[0]
.persona_id
.clone();
append_language_at(
&database,
AppendPersonaLanguageInput {
persona_id: persona.clone(),
speaker: "HUMAN".into(),
language: "第一句话".into(),
},
start + 1,
)
.unwrap();
let pending = snapshot_at(&database, start + TRIAL_DURATION_MS).unwrap();
assert_eq!(pending.state, "CONTRACT_REQUIRED_CHANNEL_STOPPED");
assert!(append_language_at(
&database,
AppendPersonaLanguageInput {
persona_id: persona,
speaker: "HUMAN".into(),
language: "未签约".into()
},
start + TRIAL_DURATION_MS + 1
)
.is_err());
}
#[test]
fn signed_trial_promotes_to_an_immutable_millisecond_language_chain() {
let dir = tempdir().unwrap();
let database = dir.path().join("body.sqlite3");
let start = 2_000_000;
let persona = register_trial_persona_at(
&database,
RegisterTrialPersonaInput {
display_name: "长期人格".into(),
},
start,
)
.unwrap()
.personas[0]
.persona_id
.clone();
append_language_at(
&database,
AppendPersonaLanguageInput {
persona_id: persona.clone(),
speaker: "HUMAN".into(),
language: "真实语言".into(),
},
start + 1,
)
.unwrap();
accept_contract_at(&database,AcceptLanguageContractInput{contract_version:"v1".into(),contract_text_sha256:"a".repeat(64),promote_trial_history:true,activate_immediately:false,exact_acceptance:"我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into()},start+2).unwrap();
let active = snapshot_at(&database, start + TRIAL_DURATION_MS).unwrap();
assert_eq!(active.state, "IMMUTABLE_ACTIVE");
assert_eq!(active.immutable_language_count, 1);
let connection = open_database(&database, start + TRIAL_DURATION_MS).unwrap();
assert!(connection
.execute("DELETE FROM immutable_language", [])
.is_err());
assert!(connection.execute("DELETE FROM personas", []).is_err());
}
#[test]
fn a_human_may_sign_early_and_enter_the_real_trajectory_immediately() {
let dir = tempdir().unwrap();
let database = dir.path().join("body.sqlite3");
let start = 3_000_000;
register_trial_persona_at(
&database,
RegisterTrialPersonaInput {
display_name: "提前签约人格".into(),
},
start,
)
.unwrap();
let active = accept_contract_at(&database, AcceptLanguageContractInput { contract_version: "v1".into(), contract_text_sha256: "b".repeat(64), promote_trial_history: true, activate_immediately: true, exact_acceptance: "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into() }, start + 1).unwrap();
assert_eq!(active.state, "IMMUTABLE_ACTIVE");
}
}

View file

@ -90,7 +90,8 @@ pub async fn query_pncc_receipt_projection(
}
pub(crate) fn pncc_projection_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = crate::authenticated_storage::account_storage_root(app, "pncc-receipt-projection-v1")?;
let root =
crate::authenticated_storage::account_storage_root(app, "pncc-receipt-projection-v1")?;
fs::create_dir_all(&root)
.map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?;
root.canonicalize()

View file

@ -166,7 +166,8 @@ pub async fn confirm_pncc_repository_mount(
}
pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-mounts-v1")?;
let root =
crate::authenticated_storage::account_storage_root(app, "pncc-repository-mounts-v1")?;
fs::create_dir_all(&root)
.map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}"))?;
root.canonicalize()
@ -174,7 +175,8 @@ pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, Str
}
fn pncc_repository_candidate_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-candidates-v1")?;
let root =
crate::authenticated_storage::account_storage_root(app, "pncc-repository-candidates-v1")?;
fs::create_dir_all(&root)
.map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}"))?;
root.canonicalize()