From 044e6d6146c20f77421348c4630cd26bd2178759 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 15:02:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=9A=E8=BF=87=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E7=9A=84=E5=B7=A5=E4=BD=9C=E5=8C=BA=E8=B7=AF=E7=94=B1=20Core?= =?UTF-8?q?=20=E7=AC=94=E8=AE=B0=E5=B9=B6=E4=BF=9D=E5=AD=98=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E6=93=8D=E4=BD=9C=E5=9B=9E=E6=89=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/agent/tools.py | 8 + backend/app/host_bridge.py | 11 +- backend/app/services/coordination.py | 4 + backend/app/services/desktop_notes.py | 116 +++++++ backend/app/services/note_service.py | 8 + backend/app/sidecar.py | 11 +- backend/tests/test_desktop_notes.py | 44 +++ docs/contracts/Host-v1契约.md | 5 + .../OpenNexus生产化实施进度-2026-09-08.md | 4 + frontend/src-tauri/build.rs | 1 + frontend/src-tauri/capabilities/main.json | 1 + .../autogenerated/workspace_operation.toml | 11 + frontend/src-tauri/src/core.rs | 10 +- frontend/src-tauri/src/lib.rs | 1 + frontend/src-tauri/src/main.rs | 55 +++- frontend/src-tauri/src/workspace.rs | 298 +++++++++++++++++- frontend/src-tauri/src/workspace_broker.rs | 161 ++++++++++ frontend/src-tauri/tests/core_workspace.rs | 154 +++++++++ 18 files changed, 881 insertions(+), 22 deletions(-) create mode 100644 backend/app/services/desktop_notes.py create mode 100644 backend/tests/test_desktop_notes.py create mode 100644 frontend/src-tauri/permissions/autogenerated/workspace_operation.toml create mode 100644 frontend/src-tauri/src/workspace_broker.rs create mode 100644 frontend/src-tauri/tests/core_workspace.rs diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index fb53ed2..6b065ca 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -117,10 +117,16 @@ class ToolRegistry: duration_ms=round((perf_counter() - started) * 1000), ) + from app import host_bridge + from uuid import NAMESPACE_URL, uuid5 + operation = str(uuid5(NAMESPACE_URL, f'opennexus:{context.run_id}:{call.tool_call_id}')) + operation_token = host_bridge.operation_id.set(operation) try: output = registered.executor(arguments, context) if inspect.isawaitable(output): output = await output + if host_bridge.active is not None and isinstance(output, dict) and call.name.startswith('notes.'): + output = {**output, 'operation_id': operation} return ToolResult( tool_call_id=call.tool_call_id, name=call.name, @@ -146,3 +152,5 @@ class ToolRegistry: error_message=str(exc), duration_ms=round((perf_counter() - started) * 1000), ) + finally: + host_bridge.operation_id.reset(operation_token) diff --git a/backend/app/host_bridge.py b/backend/app/host_bridge.py index 94d109e..9dcab5d 100644 --- a/backend/app/host_bridge.py +++ b/backend/app/host_bridge.py @@ -17,7 +17,7 @@ class HostBridge: request_id = uuid.uuid4().hex result = queue.Queue(maxsize=1) payload = json.dumps({"rpc": method, "request_id": request_id, "params": params}, separators=(",", ":")) - if len(payload.encode()) > 131072: + if len(payload.encode()) > (8 * 1024 * 1024): raise RuntimeError("HOST_REQUEST_TOO_LARGE") with self.lock: if self.closed.is_set(): @@ -42,8 +42,8 @@ class HostBridge: def listen(self, on_disconnect): try: - while line := self.reader.readline(131073): - if len(line) > 131072: + while line := self.reader.readline((8 * 1024 * 1024 + 1)): + if len(line) > (8 * 1024 * 1024): break message = json.loads(line) with self.lock: @@ -65,3 +65,8 @@ class HostBridge: active: HostBridge | None = None + +# Set only by authenticated Host HTTP transport; inherited by Agent tasks. +from contextvars import ContextVar +vault_id: ContextVar[str | None] = ContextVar("host_vault_id", default=None) +operation_id: ContextVar[str | None] = ContextVar("host_operation_id", default=None) diff --git a/backend/app/services/coordination.py b/backend/app/services/coordination.py index 45381f5..d34951e 100644 --- a/backend/app/services/coordination.py +++ b/backend/app/services/coordination.py @@ -61,6 +61,10 @@ def serialized_vault_mutation(operation): @wraps(operation) async def wrapped(*args, **kwargs): async with vault_mutation_lock(): + from app.config import get_settings + if get_settings().environment == 'desktop' and operation.__module__ == 'app.services.note_service': + from app.services.desktop_notes import mutate + return await mutate(operation.__name__, *args, **kwargs) with web_vault_ownership(): return await operation(*args, **kwargs) diff --git a/backend/app/services/desktop_notes.py b/backend/app/services/desktop_notes.py new file mode 100644 index 0000000..23a9dfd --- /dev/null +++ b/backend/app/services/desktop_notes.py @@ -0,0 +1,116 @@ +"""Desktop note adapter: Markdown and stable identities are owned only by Rust. + +No fallback to the Core's unbound Vault or its stale SQLite note projection. +""" +from __future__ import annotations +import asyncio +from datetime import datetime, timezone +from pathlib import PurePosixPath +from uuid import uuid4 +import yaml +from app import host_bridge +from app.contracts import Note, NoteSummary +from app.errors import ApiError +from app.knowledge.parser import parse_note, _frontmatter +from app.services.vault_paths import normalize_folder, normalize_entry_name, safe_note_filename + + +def call(method: str, **params): + vault = host_bridge.vault_id.get() + if not vault: + raise ApiError(409, 'WORKSPACE_NOT_OPEN', '请先打开授权工作区。') + if host_bridge.active is None: + raise ApiError(503, 'HOST_UNAVAILABLE', 'Host 不可用。') + try: + return host_bridge.active.call('workspace.' + method, vault_id=vault, **params) + except RuntimeError as exc: + code = str(exc) + status = 404 if code in {'FILE_NOT_FOUND', 'OPERATION_NOT_FOUND'} else 409 + if code in {'HOST_UNAVAILABLE', 'HOST_TIMEOUT'}: status = 503 + raise ApiError(status, code, '工作区操作未完成,请检查当前工作区和操作结果。', + {'operation_id': params.get('operation_id'), 'vault_id': vault}) from None + + +def note_from_document(document: dict) -> Note: + path = PurePosixPath(document['path']) + parsed = parse_note(markdown=document['content'], file_path=str(path), + folder=str(path.parent) if str(path.parent) != '.' else '', + note_id=document['file_id'], + created_at=datetime.fromtimestamp(document['created_at'], timezone.utc), + updated_at=datetime.fromtimestamp(document['updated_at'], timezone.utc)) + return Note(note_id=parsed.note_id, title=parsed.title, file_path=parsed.file_path, + tags=parsed.tags, created_at=parsed.created_at, updated_at=parsed.updated_at, + markdown=document['content'], blocks=parsed.blocks) + + +def metadata(markdown: str, title: str | None, tags: list[str] | None) -> str: + if title is None and tags is None: return markdown + header = _frontmatter(markdown) + try: + values = yaml.safe_load(header[0]) if header else {} + except yaml.YAMLError: + raise ApiError(422, 'INVALID_FRONTMATTER', '元数据格式无效,请先修复原文。') from None + if values is None: values = {} + if not isinstance(values, dict): raise ApiError(422, 'INVALID_FRONTMATTER', '元数据必须是字段映射。') + if title is not None: values['title'] = title + if tags is not None: values['tags'] = tags + return '---\n' + yaml.safe_dump(values, allow_unicode=True, sort_keys=False) + '---\n' + (markdown[header[1]:] if header else markdown) + + +async def get_note(note_id: str) -> Note | None: + try: + return note_from_document(await asyncio.to_thread(call, 'read', file_id=note_id)) + except ApiError as exc: + if exc.code == 'FILE_NOT_FOUND': return None + raise + + +async def mutate(name: str, *args, **kwargs): + operation_id = host_bridge.operation_id.get() or str(uuid4()) + if name == 'create_note': + folder = normalize_folder(kwargs.get('folder')) + path = '/'.join(filter(None, [folder, safe_note_filename(kwargs['title'])])) + content = metadata(kwargs['markdown'], kwargs['title'], kwargs.get('tags') or None) + receipt = await asyncio.to_thread(call, 'write', path=path, expected='', content=content, operation_id=operation_id) + return await get_note(receipt['result']['file_id']) + note_id = args[0] if args else kwargs.pop('note_id') + document = await asyncio.to_thread(call, 'read', file_id=note_id) + path = document['path'] + if name == 'update_note': + expected = kwargs.get('expected_content_hash') or document['hash'] + content = document['content'] if kwargs.get('markdown') is None else kwargs['markdown'] + tags = kwargs.get('tags') + if tags is None and kwargs.get('markdown') is not None: + tags = note_from_document(document).tags + content = metadata(content, kwargs.get('title'), tags) + await asyncio.to_thread(call, 'write', path=path, expected=expected, content=content, operation_id=operation_id) + return await get_note(note_id) + if name in {'move_note', 'rename_note', 'delete_note'}: + destination = '' + if name == 'move_note': + destination = '/'.join(filter(None, [normalize_folder(kwargs['folder']), PurePosixPath(path).name])) + if name == 'rename_note': + parent = str(PurePosixPath(path).parent) + destination = '/'.join(filter(None, ['' if parent == '.' else parent, normalize_entry_name(kwargs['file_name'], markdown=True)])) + if destination == path: return await get_note(note_id) + await asyncio.to_thread(call, 'mutate', kind='delete' if name == 'delete_note' else 'rename', + path=path, destination=destination, expected=document['hash'], operation_id=operation_id) + return True if name == 'delete_note' else await get_note(note_id) + raise ApiError(409, 'WORKSPACE_OPERATION_UNSUPPORTED', '此操作尚未接入 Host。') + + +def list_notes(*, limit: int, offset: int, folder: str | None, tag: str | None): + entries, position = [], 0 + while True: + page = call('list', offset=position, limit=1000) + entries.extend(page['items']) + position += len(page['items']) + if position >= page['total'] or not page['items']: break + notes = [] + for entry in entries: + parent = str(PurePosixPath(entry['path']).parent) + if folder is not None and ('' if parent == '.' else parent) != normalize_folder(folder): continue + note = note_from_document(call('read', file_id=entry['file_id'])) + if tag is not None and tag not in note.tags: continue + notes.append(NoteSummary(**note.model_dump(exclude={'markdown', 'blocks'}))) + return notes[offset:offset + limit], len(notes) diff --git a/backend/app/services/note_service.py b/backend/app/services/note_service.py index a025723..a7a7901 100644 --- a/backend/app/services/note_service.py +++ b/backend/app/services/note_service.py @@ -171,6 +171,10 @@ async def create_note(*, title: str, markdown: str, folder: str | None, tags: li async def get_note(note_id: str) -> Note | None: + from app.config import get_settings + if get_settings().environment == 'desktop': + from app.services.desktop_notes import get_note as desktop_get_note + return await desktop_get_note(note_id) record = repository.get_note_record(note_id) if record is None: return None @@ -375,6 +379,10 @@ async def delete_note(note_id: str) -> bool: def list_notes(*, limit: int, offset: int, folder: str | None, tag: str | None) -> tuple[list[NoteSummary], int]: + from app.config import get_settings + if get_settings().environment == 'desktop': + from app.services.desktop_notes import list_notes as desktop_list_notes + return desktop_list_notes(limit=limit, offset=offset, folder=folder, tag=tag) items, total = repository.list_note_summaries(limit=limit, offset=offset, folder=folder, tag=tag) return [NoteSummary(**item) for item in items], total diff --git a/backend/app/sidecar.py b/backend/app/sidecar.py index 0b30abf..6431f72 100644 --- a/backend/app/sidecar.py +++ b/backend/app/sidecar.py @@ -77,7 +77,16 @@ class SessionAuth: (b"cache-control", b"no-store")]}) await send({"type": "http.response.body", "body": body}) return - await self.app(scope, receive, send) + from app import host_bridge + vault = single(b"x-opennexus-vault").decode("ascii", errors="replace") + token = host_bridge.vault_id.set(vault if re.fullmatch(r"[0-9a-f-]{36}", vault) else None) + operation = single(b"x-request-id").decode("ascii", errors="replace") + operation_token = host_bridge.operation_id.set(operation if re.fullmatch(r"[0-9a-f-]{36}", operation) else None) + try: + await self.app(scope, receive, send) + finally: + host_bridge.vault_id.reset(token) + host_bridge.operation_id.reset(operation_token) def main() -> int: diff --git a/backend/tests/test_desktop_notes.py b/backend/tests/test_desktop_notes.py new file mode 100644 index 0000000..eee6bdc --- /dev/null +++ b/backend/tests/test_desktop_notes.py @@ -0,0 +1,44 @@ +"""Host-only adapter contracts use fake documents; process coverage lives in Rust.""" +from types import SimpleNamespace +import pytest +import asyncio +from app import host_bridge +from app.errors import ApiError +from app.services import desktop_notes, note_service + + +def test_desktop_note_read_never_falls_back_without_bound_vault(monkeypatch): + monkeypatch.setattr('app.config.get_settings', lambda: SimpleNamespace(environment='desktop')) + token = host_bridge.vault_id.set(None) + try: + with pytest.raises(ApiError, match='授权工作区') as error: + asyncio.run(note_service.get_note('note-from-old-core-index')) + assert error.value.code == 'WORKSPACE_NOT_OPEN' + finally: + host_bridge.vault_id.reset(token) + + +def test_update_preserves_tags_and_carries_explicit_cas_and_operation(monkeypatch): + document = dict(file_id='stable-id', path='notes/a.md', hash='observed-hash', + content='---\ntags: [original]\n---\nold', created_at=0, updated_at=1) + calls = [] + def call(method, **params): + calls.append((method, params)) + return document if method == 'read' else {'state': 'committed'} + monkeypatch.setattr(desktop_notes, 'call', call) + token = host_bridge.operation_id.set('b9e1da18-c442-4c1c-a7d7-4ac83b58c849') + try: + asyncio.run(desktop_notes.mutate('update_note', 'stable-id', markdown='new body', expected_content_hash='caller-hash')) + finally: + host_bridge.operation_id.reset(token) + write = next(params for method, params in calls if method == 'write') + assert write['expected'] == 'caller-hash' + assert write['operation_id'] == 'b9e1da18-c442-4c1c-a7d7-4ac83b58c849' + assert 'original' in write['content'] and write['content'].endswith('new body') + + +def test_metadata_keeps_unrelated_frontmatter_and_rejects_non_mapping(): + result = desktop_notes.metadata('---\ncustom: keep\ntags: [old]\n---\nbody', None, []) + assert 'custom: keep' in result and 'tags: []' in result and result.endswith('body') + with pytest.raises(ApiError): + desktop_notes.metadata('---\ntitle: [broken\n---\nbody', 'new', None) diff --git a/docs/contracts/Host-v1契约.md b/docs/contracts/Host-v1契约.md index 1861ecb..231c0ff 100644 --- a/docs/contracts/Host-v1契约.md +++ b/docs/contracts/Host-v1契约.md @@ -19,6 +19,7 @@ | `workspace_tree` | Markdown 与目录树;file_id/path/hash/revision/deleted/is_folder | | `workspace_read` | path,返回条目与 UTF-8 content | | `workspace_write` | path、expected(SHA-256)、content;返回新条目 | +| `workspace_operation` | vault_id、operation_id;返回 pending/committed/conflict 与提交时条目,未知 ID 返回 null | | `workspace_rename` | path、destination、expected;文件 ID 保持不变 | | `workspace_delete` | path、expected;正文保存在 `.ainote/trash` | | `workspace_mkdir` | path,相对当前授权 Vault | @@ -33,6 +34,10 @@ Windows 使用独立隐藏窗口注册 WTS 会话通知,锁屏、注销及本 ## 写入及恢复 +Workspace schema 2 新增持久操作回执。`workspace_write` 可选 operation_id(UUID),同 ID、同载荷重放返回原提交条目;不同载荷返回 OPERATION_PAYLOAD_CONFLICT。回执与文件元数据、outbox 在同一 SQLite 事务提交,即使之后再次编辑,查询仍返回对应操作的原始结果。schema 1 升级前用 SQLite VACUUM INTO 保存一致备份;旧 schema 1 Host 拒绝写入 schema 2。 + +Core 的 `workspace.list/read/write/mutate/operation` RPC 通过受控管道转发,严格拒绝未知字段并校验 Host HTTP 请求捕获的 vault_id。切换或撤销工作区后旧任务返回 VAULT_PERMISSION_CHANGED。普通 HTTP 与 SSE 均由 Rust 添加工作区头,WebView 不能指定。Core 笔记 CRUD 不再写入 unbound-vault,也不从旧 Core 笔记索引回退读取;Markdown 元数据随正文保存,笔记 ID 使用 Rust file_id。当前 Core 笔记 RPC 单篇上限 1 MiB,管道帧上限 8 MiB;大媒体传输与检索投影另行实现,不宣称已满足完整 A-03。 + OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vault_mutation` 使用同一 OS 文件锁;发现 `.ainote/host.sqlite3` 后拒绝 Web 写入,不自动降级。已有个人 Vault 不会被测试读取或迁移。 保存前对比实际磁盘 SHA-256,先持久化包含内容的 journal,再同目录临时文件刷盘和替换,最后提交元数据及 outbox。文件已替换但数据库未提交时,启动恢复补齐同一 operation_id。摘要不匹配则保留 journal 冲突及外部正文。remote origin 不再次入 outbox。outbox 目前只持久化,**尚无上传/拉取循环**。 diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 04ad1a2..a761c0f 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -6,6 +6,10 @@ ## 持续实施增量 +- Core 笔记 CRUD 已经由受控管道接入当前授权 Rust Vault,保留稳定 file_id,支持 CAS 和跨 Vault 拒绝。新增 schema 2 操作回执,记录写入/移动/删除提交结果,升级前保存 schema 1 一致备份。同一写入操作重放 100 次不新增 outbox,也不覆盖后续编辑。 +- 真实 Python Core + 临时 Rust Vault 集成测试覆盖创建、列表、修改、并发冲突、移动、删除、跨 Vault 拒绝和提交查询;Rust desktop 全目标 35 项通过。Core 检索投影、后台任务取消的完整提交边界、大媒体和 UI 提交确认仍需继续实现,不能将 CRUD 接通等同整个 AI 工作流验收完成。 +- 此增量后端全量 897 项、前端全量 94 文件/506 项通过;Rust desktop 全目标 Clippy 与文档链接检查通过。旧 Core 打包产物尚未重新构建,不将开发解释器进程测试作为最新安装包证据。 + - 已修复多实例凭据覆盖与桌面请求取消/超时,证据见[全量回归](OpenNexus验收修复与回归-2026-09-08.md)。 - 新增加密凭据备份、原生确认恢复、恢复前留存和逐条完整性验证。Windows 新增 WTS 锁屏/注销/断开通知,原子撤销解析资格;解锁不会随系统返回而自动恢复。设置页同步锁定状态,提供备份和恢复入口。 - 备份恢复、错误口令保护、锁屏后解析/写入拒绝、原生窗口通知测试通过;后者向专用测试窗口注入消息,不等于真实锁屏时延验收。前端两项交互测试、类型检查、Rust desktop 全目标 Clippy 通过。凭据旧库确认清除和迁移故障矩阵仍待补齐。 diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index 7e347f2..0067e8f 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -34,6 +34,7 @@ fn main() { "workspace_tree", "workspace_read", "workspace_write", + "workspace_operation", "workspace_rename", "workspace_delete", "workspace_mkdir", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index e319656..b093a98 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -25,6 +25,7 @@ "allow-workspace-tree", "allow-workspace-read", "allow-workspace-write", + "allow-workspace-operation", "allow-workspace-rename", "allow-workspace-delete", "allow-workspace-mkdir", diff --git a/frontend/src-tauri/permissions/autogenerated/workspace_operation.toml b/frontend/src-tauri/permissions/autogenerated/workspace_operation.toml new file mode 100644 index 0000000..db19276 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/workspace_operation.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-workspace-operation" +description = "Enables the workspace_operation command without any pre-configured scope." +commands.allow = ["workspace_operation"] + +[[permission]] +identifier = "deny-workspace-operation" +description = "Denies the workspace_operation command without any pre-configured scope." +commands.deny = ["workspace_operation"] diff --git a/frontend/src-tauri/src/core.rs b/frontend/src-tauri/src/core.rs index 2039155..aadd4e9 100644 --- a/frontend/src-tauri/src/core.rs +++ b/frontend/src-tauri/src/core.rs @@ -363,11 +363,15 @@ impl CoreSupervisor { let mut reader = BufReader::new(stdout); loop { let mut line = Zeroizing::new(Vec::new()); - match reader.by_ref().take(131073).read_until(b'\n', &mut line) { + match reader + .by_ref() + .take(8 * 1024 * 1024 + 1) + .read_until(b'\n', &mut line) + { Ok(0) | Err(_) => break, _ => {} } - if line.len() > 131072 { + if line.len() > 8 * 1024 * 1024 { break; } let Ok(message) = serde_json::from_slice::(&line) else { @@ -393,7 +397,7 @@ impl CoreSupervisor { break; }; let mut bytes = Zeroizing::new(bytes); - if bytes.len() > 131072 { + if bytes.len() > 8 * 1024 * 1024 { break; } bytes.push(b'\n'); diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 182bc1a..b40d17b 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -9,3 +9,4 @@ mod runtime_compat; #[cfg(windows)] pub mod session_lock; pub mod workspace; +pub mod workspace_broker; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 007e178..9931efa 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -18,7 +18,7 @@ use zeroize::Zeroizing; #[derive(Default)] struct Host { requests: Requests, - workspace: Mutex>, + workspace: Arc>>, recent: Mutex>, core: Arc>>, credentials: Arc>>, @@ -162,6 +162,12 @@ async fn core_request(request: CoreRequest, host: State<'_, Host>) -> Result) -> Result= 16 || running.contains_key(&request_id) { @@ -338,6 +353,7 @@ fn core_stream( let mut request = client.request(reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?, session.url) .header("Authorization", session.authorization.as_str()) .header("X-Core-Generation", session.generation).header("Accept", "text/event-stream"); + if let Some(vault_id) = vault_id { request = request.header("X-OpenNexus-Vault", vault_id); } if let Some(body) = body { request = request.json(&body); } if let Some(id) = last_event_id { request = request.header("Last-Event-ID", id); } let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?; @@ -619,12 +635,32 @@ fn workspace_write( path: String, expected: String, content: String, + operation_id: Option, ) -> Result { with_workspace(&host, |ws| { - ws.write(&path, &expected, content.as_bytes(), "local") + ws.write_operation( + &path, + &expected, + content.as_bytes(), + "local", + &operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + ) }) } #[tauri::command] +fn workspace_operation( + host: State<'_, Host>, + vault_id: String, + operation_id: String, +) -> Result, String> { + let guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?; + let workspace = guard.as_ref().ok_or("WORKSPACE_NOT_OPEN")?; + if workspace.vault_id != vault_id { + return Err("VAULT_PERMISSION_CHANGED".into()); + } + workspace.operation(&operation_id).map_err(|e| e.code) +} +#[tauri::command] fn workspace_rename( host: State<'_, Host>, path: String, @@ -730,7 +766,21 @@ fn main() { include_str!(concat!(env!("OUT_DIR"), "/core-manifest.json")).to_owned(), ) }; + let workspace_state = app.state::().workspace.clone(); let core = core.with_broker(Arc::new(move |request| { + if request["rpc"] + .as_str() + .is_some_and(|method| method.starts_with("workspace.")) + { + return notesagent_host::workspace_broker::dispatch( + workspace_state + .lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("WORKSPACE_NOT_OPEN")?, + request, + ); + } credential_state .lock() .map_err(|_| "HOST_BUSY")? @@ -786,6 +836,7 @@ fn main() { workspace_tree, workspace_read, workspace_write, + workspace_operation, workspace_rename, workspace_delete, workspace_mkdir diff --git a/frontend/src-tauri/src/workspace.rs b/frontend/src-tauri/src/workspace.rs index b0a5555..63f814f 100644 --- a/frontend/src-tauri/src/workspace.rs +++ b/frontend/src-tauri/src/workspace.rs @@ -135,16 +135,22 @@ impl Workspace { let db = Connection::open(db_path)?; db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?; let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; - if version > 1 { + if version > 2 { return Err(HostError::new("SCHEMA_INCOMPATIBLE")); } + if version == 1 { + // Independent, complete SQLite backup before the schema ownership change. + let backup = managed.join(format!("host-schema1-{}.sqlite3", Uuid::new_v4())); + db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?; + } db.execute_batch("BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS identity (id TEXT NOT NULL); CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY,path TEXT UNIQUE NOT NULL,hash TEXT NOT NULL,revision INTEGER NOT NULL,deleted INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS journal (operation_id TEXT PRIMARY KEY,file_id TEXT NOT NULL,path TEXT NOT NULL,expected TEXT NOT NULL,content BLOB NOT NULL,origin TEXT NOT NULL,state TEXT NOT NULL); CREATE TABLE IF NOT EXISTS file_ops (id TEXT PRIMARY KEY,kind TEXT NOT NULL,path TEXT NOT NULL,destination TEXT NOT NULL,hash TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending'); CREATE TABLE IF NOT EXISTS outbox (operation_id TEXT PRIMARY KEY,file_id TEXT NOT NULL,revision INTEGER NOT NULL,path TEXT NOT NULL,hash TEXT NOT NULL,operation TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending'); - PRAGMA user_version=1; COMMIT;")?; + CREATE TABLE IF NOT EXISTS operations (operation_id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT); + PRAGMA user_version=2; COMMIT;")?; let vault_id: String = db .query_row("SELECT id FROM identity", [], |r| r.get(0)) .optional()? @@ -281,17 +287,36 @@ impl Workspace { } pub fn read(&mut self, path: &str) -> Result { - self.scan()?; + let content = fs::read_to_string(self.resolve(path)?)?; + let digest = hash(content.as_bytes()); + let previous = self.entry(path)?; + if previous + .as_ref() + .is_none_or(|entry| entry.hash != digest || entry.deleted) + { + let id = previous.map_or_else(|| Uuid::new_v4().to_string(), |entry| entry.file_id); + self.db.execute("INSERT INTO files VALUES (?1,?2,?3,1,0) ON CONFLICT(path) DO UPDATE SET hash=excluded.hash,revision=files.revision+1,deleted=0", params![id,path,digest])?; + } let entry = self .entry(path)? .ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?; - let content = fs::read_to_string(self.resolve(path)?)?; if hash(content.as_bytes()) != entry.hash { return Err(HostError::new("REVISION_CONFLICT")); } Ok(Document { entry, content }) } + pub fn path_for_id(&self, file_id: &str) -> Result { + self.db + .query_row( + "SELECT path FROM files WHERE id=?1 AND deleted=0", + [file_id], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| HostError::new("FILE_NOT_FOUND")) + } + pub fn write( &mut self, path: &str, @@ -299,12 +324,73 @@ impl Workspace { content: &[u8], origin: &str, ) -> Result { + self.write_operation(path, expected, content, origin, &Uuid::new_v4().to_string()) + } + + pub fn operation(&self, operation_id: &str) -> Result> { + let value: Option<(String, Option)> = self + .db + .query_row( + "SELECT state,result FROM operations WHERE operation_id=?1", + [operation_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + value + .map(|(state, result)| { + let result: Option = result + .map(|value| { + serde_json::from_str(&value).map_err(|_| HostError::new("DATABASE_ERROR")) + }) + .transpose()?; + Ok(serde_json::json!({"operation_id":operation_id,"state":state,"result":result})) + }) + .transpose() + } + + pub fn write_operation( + &mut self, + path: &str, + expected: &str, + content: &[u8], + origin: &str, + operation_id: &str, + ) -> Result { + if Uuid::parse_str(operation_id).is_err() { + return Err(HostError::new("OPERATION_ID_INVALID")); + } if content.len() > 100 * 1024 * 1024 { return Err(HostError::new("FILE_TOO_LARGE")); } if origin != "local" && origin != "remote" { return Err(HostError::new("INVALID_ORIGIN")); } + let fingerprint = hash( + &serde_json::to_vec(&(path, expected, hash(content), origin)) + .map_err(|_| HostError::new("INVALID_OPERATION"))?, + ); + let previous: Option = self + .db + .query_row( + "SELECT fingerprint FROM operations WHERE operation_id=?1", + [operation_id], + |row| row.get(0), + ) + .optional()?; + if let Some(previous) = previous { + if previous != fingerprint { + return Err(HostError::new("OPERATION_PAYLOAD_CONFLICT")); + } + self.recover()?; + let receipt = self + .operation(operation_id)? + .ok_or_else(|| HostError::new("DATABASE_ERROR"))?; + if receipt["state"] != "committed" { + return Err(HostError::new("RECOVERY_CONFLICT")); + } + return serde_json::from_value(receipt["result"].clone()) + .map_err(|_| HostError::new("DATABASE_ERROR")); + } let target = self.resolve(path)?; let current = if target.exists() { hash(&fs::read(&target)?) @@ -317,12 +403,17 @@ impl Workspace { let file_id = self .entry(path)? .map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id); - let operation_id = Uuid::new_v4().to_string(); - self.db.execute( + let tx = self.db.transaction()?; + tx.execute( + "INSERT INTO operations VALUES (?1,?2,'pending',NULL)", + params![operation_id, fingerprint], + )?; + tx.execute( "INSERT INTO journal VALUES (?1,?2,?3,?4,?5,?6,'pending')", params![operation_id, file_id, path, expected, content, origin], )?; - self.apply_journal(&operation_id, &file_id, path, expected, content, origin)?; + tx.commit()?; + self.apply_journal(operation_id, &file_id, path, expected, content, origin)?; self.entry(path)? .ok_or_else(|| HostError::new("FILE_NOT_FOUND")) } @@ -344,10 +435,16 @@ impl Workspace { String::new() }; if current != expected && current != digest { - self.db.execute( + let tx = self.db.transaction()?; + tx.execute( "UPDATE journal SET state='conflict' WHERE operation_id=?1", [operation_id], )?; + tx.execute( + "UPDATE operations SET state='conflict' WHERE operation_id=?1", + [operation_id], + )?; + tx.commit()?; return Err(HostError::new("RECOVERY_CONFLICT")); } if current != digest { @@ -369,6 +466,25 @@ impl Workspace { if origin == "local" { tx.execute("INSERT OR IGNORE INTO outbox SELECT ?1,id,revision,path,hash,'put',?2,'pending' FROM files WHERE path=?3", params![operation_id,content,path])?; } + let entry = tx.query_row( + "SELECT id,path,hash,revision,deleted FROM files WHERE path=?1", + [path], + |row| { + Ok(Entry { + file_id: row.get(0)?, + path: row.get(1)?, + hash: row.get(2)?, + revision: row.get(3)?, + deleted: row.get(4)?, + is_folder: false, + }) + }, + )?; + let result = serde_json::to_string(&entry).map_err(|_| HostError::new("DATABASE_ERROR"))?; + tx.execute( + "UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1", + params![operation_id, result], + )?; tx.execute("DELETE FROM journal WHERE operation_id=?1", [operation_id])?; tx.commit()?; Ok(()) @@ -451,6 +567,76 @@ impl Workspace { destination: &str, expected: &str, ) -> Result { + self.prepare_file_op_with_id( + kind, + path, + destination, + expected, + &Uuid::new_v4().to_string(), + ) + } + + pub fn mutate_operation( + &mut self, + kind: &str, + path: &str, + destination: &str, + expected: &str, + operation_id: &str, + ) -> Result { + let id = self.prepare_file_op_with_id(kind, path, destination, expected, operation_id)?; + if self + .operation(&id)? + .is_some_and(|v| v["state"] == "committed") + { + return self + .operation(&id)? + .ok_or_else(|| HostError::new("DATABASE_ERROR")); + } + self.apply_file_op(&id)?; + self.operation(&id)? + .ok_or_else(|| HostError::new("DATABASE_ERROR")) + } + + fn prepare_file_op_with_id( + &mut self, + kind: &str, + path: &str, + destination: &str, + expected: &str, + id: &str, + ) -> Result { + if !matches!(kind, "rename" | "delete") || Uuid::parse_str(id).is_err() { + return Err(HostError::new("INVALID_OPERATION")); + } + let fingerprint = hash( + &serde_json::to_vec(&(kind, path, destination, expected)) + .map_err(|_| HostError::new("INVALID_OPERATION"))?, + ); + let previous: Option = self + .db + .query_row( + "SELECT fingerprint FROM operations WHERE operation_id=?1", + [id], + |row| row.get(0), + ) + .optional()?; + if let Some(previous) = previous { + if previous != fingerprint { + return Err(HostError::new("OPERATION_PAYLOAD_CONFLICT")); + } + self.recover()?; + if self + .operation(id)? + .is_some_and(|v| v["state"] == "conflict") + { + return Err(HostError::new("RECOVERY_CONFLICT")); + } + return Ok(id.to_owned()); + } + if kind == "rename" && self.resolve(destination)?.exists() { + return Err(HostError::new("PATH_CONFLICT")); + } let source = self.resolve(path)?; if !source.is_file() { return Err(HostError::new("FILE_NOT_FOUND")); @@ -461,12 +647,17 @@ impl Workspace { } self.entry(path)? .ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?; - let id = Uuid::new_v4().to_string(); - self.db.execute( + let tx = self.db.transaction()?; + tx.execute( + "INSERT INTO operations VALUES (?1,?2,'pending',NULL)", + params![id, fingerprint], + )?; + tx.execute( "INSERT INTO file_ops VALUES (?1,?2,?3,?4,?5,?6,'pending')", params![id, kind, path, destination, expected, content], )?; - Ok(id) + tx.commit()?; + Ok(id.to_owned()) } fn apply_file_op(&mut self, id: &str) -> Result<()> { @@ -499,8 +690,13 @@ impl Workspace { let target_conflict = target.exists() && (linked(&target)? || hash(&fs::read(&target)?) != expected); if source_conflict || target_conflict { - self.db - .execute("UPDATE file_ops SET state='conflict' WHERE id=?1", [id])?; + let tx = self.db.transaction()?; + tx.execute("UPDATE file_ops SET state='conflict' WHERE id=?1", [id])?; + tx.execute( + "UPDATE operations SET state='conflict' WHERE operation_id=?1", + [id], + )?; + tx.commit()?; return Err(HostError::new("RECOVERY_CONFLICT")); } // journal 保留完整内容,目标刷盘后才删除来源;两处崩溃均可幂等重放。 @@ -533,6 +729,20 @@ impl Workspace { tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,'','delete',X'','pending' FROM files WHERE id=?2", params![id,previous.file_id])?; } tx.execute("DELETE FROM file_ops WHERE id=?1", [id])?; + let mut result = previous; + result.revision += 1; + if kind == "rename" { + result.path = destination; + } else { + result.deleted = true; + } + tx.execute( + "UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1", + params![ + id, + serde_json::to_string(&result).map_err(|_| HostError::new("DATABASE_ERROR"))? + ], + )?; tx.commit()?; Ok(()) } @@ -599,6 +809,68 @@ mod tests { assert_eq!(ws.pending_count().unwrap(), 2); } + #[test] + fn operation_receipt_survives_reopen_and_replay_after_later_edit() { + let dir = tempfile::tempdir().unwrap(); + let operation = Uuid::new_v4().to_string(); + let mut ws = Workspace::open(dir.path()).unwrap(); + let first = ws + .write_operation("a.md", "", b"first", "local", &operation) + .unwrap(); + ws.write("a.md", &first.hash, b"second", "local").unwrap(); + drop(ws); + let mut ws = Workspace::open(dir.path()).unwrap(); + for _ in 0..100 { + let replay = ws + .write_operation("a.md", "", b"first", "local", &operation) + .unwrap(); + assert_eq!(replay.revision, first.revision); + assert_eq!(replay.hash, first.hash); + } + assert_eq!(ws.read("a.md").unwrap().content, "second"); + assert_eq!(ws.pending_count().unwrap(), 2); + assert_eq!( + ws.operation(&operation).unwrap().unwrap()["state"], + "committed" + ); + assert_eq!( + ws.write_operation("a.md", "", b"changed-payload", "local", &operation) + .unwrap_err() + .code, + "OPERATION_PAYLOAD_CONFLICT" + ); + } + + #[test] + fn schema_upgrade_preserves_a_readable_previous_database() { + let dir = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(dir.path()).unwrap(); + ws.write("a.md", "", b"old-data", "local").unwrap(); + ws.db + .execute_batch("DROP TABLE operations; PRAGMA user_version=1;") + .unwrap(); + drop(ws); + let ws = Workspace::open(dir.path()).unwrap(); + assert_eq!(ws.pending_count().unwrap(), 1); + let backup = fs::read_dir(dir.path().join(".ainote")) + .unwrap() + .filter_map(|e| e.ok()) + .find(|e| e.file_name().to_string_lossy().starts_with("host-schema1-")) + .unwrap(); + let old = Connection::open(backup.path()).unwrap(); + assert_eq!( + old.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + old.query_row("SELECT COUNT(*) FROM outbox", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + } + #[test] fn unsafe_paths_external_change_and_remote_origin() { let dir = tempfile::tempdir().unwrap(); diff --git a/frontend/src-tauri/src/workspace_broker.rs b/frontend/src-tauri/src/workspace_broker.rs new file mode 100644 index 0000000..1579dd5 --- /dev/null +++ b/frontend/src-tauri/src/workspace_broker.rs @@ -0,0 +1,161 @@ +//! Narrow Core RPC. Every request is bound to the Vault captured by the Host transport. +use crate::workspace::Workspace; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Read { + vault_id: String, + file_id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct List { + vault_id: String, + offset: usize, + limit: usize, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Write { + vault_id: String, + path: String, + expected: String, + content: String, + operation_id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Operation { + vault_id: String, + operation_id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Mutation { + vault_id: String, + path: String, + destination: String, + expected: String, + operation_id: String, + kind: String, +} + +fn bound(ws: &Workspace, vault_id: &str) -> Result<(), String> { + if ws.vault_id != vault_id { + return Err("VAULT_PERMISSION_CHANGED".into()); + } + Ok(()) +} +fn decode(value: &Value) -> Result { + serde_json::from_value(value.clone()).map_err(|_| "WORKSPACE_REQUEST_INVALID".into()) +} +pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result { + let params = &request["params"]; + match request["rpc"].as_str().unwrap_or_default() { + "workspace.list" => { + let p: List = decode(params)?; + bound(ws, &p.vault_id)?; + if !(1..=1000).contains(&p.limit) { + return Err("WORKSPACE_REQUEST_INVALID".into()); + } + let mut entries = ws.scan().map_err(|e| e.code)?; + entries.retain(|e| !e.deleted && !e.is_folder); + entries.sort_by(|a, b| a.path.cmp(&b.path)); + let total = entries.len(); + Ok( + json!({"items":entries.into_iter().skip(p.offset).take(p.limit).collect::>(),"total":total}), + ) + } + "workspace.read" => { + let p: Read = decode(params)?; + bound(ws, &p.vault_id)?; + let relative = ws.path_for_id(&p.file_id).map_err(|e| e.code)?; + let path = ws.resolve(&relative).map_err(|e| e.code)?; + let metadata = std::fs::metadata(path).map_err(|_| "FILESYSTEM_ERROR")?; + if metadata.len() > 1024 * 1024 { + return Err("CORE_NOTE_TOO_LARGE".into()); + } + let document = ws.read(&relative).map_err(|e| e.code)?; + let timestamp = |value: std::io::Result| { + value + .ok() + .and_then(|v| v.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|v| v.as_secs()) + .unwrap_or(0) + }; + let mut value = serde_json::to_value(document).map_err(|_| "HOST_SERIALIZE_FAILED")?; + value["created_at"] = json!(timestamp(metadata.created())); + value["updated_at"] = json!(timestamp(metadata.modified())); + Ok(value) + } + "workspace.write" => { + let p: Write = decode(params)?; + bound(ws, &p.vault_id)?; + if p.content.len() > 1024 * 1024 || !p.path.to_ascii_lowercase().ends_with(".md") { + return Err("CORE_NOTE_TOO_LARGE".into()); + } + let entry = ws + .write_operation( + &p.path, + &p.expected, + p.content.as_bytes(), + "local", + &p.operation_id, + ) + .map_err(|e| e.code)?; + Ok(json!({"operation_id":p.operation_id,"state":"committed","result":entry})) + } + "workspace.operation" => { + let p: Operation = decode(params)?; + bound(ws, &p.vault_id)?; + ws.operation(&p.operation_id) + .map_err(|e| e.code)? + .ok_or("OPERATION_NOT_FOUND".into()) + } + "workspace.mutate" => { + let p: Mutation = decode(params)?; + bound(ws, &p.vault_id)?; + if !p.path.to_ascii_lowercase().ends_with(".md") + || (p.kind == "rename" && !p.destination.to_ascii_lowercase().ends_with(".md")) + { + return Err("WORKSPACE_REQUEST_INVALID".into()); + } + ws.mutate_operation( + &p.kind, + &p.path, + &p.destination, + &p.expected, + &p.operation_id, + ) + .map_err(|e| e.code) + } + _ => Err("HOST_METHOD_DENIED".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_stale_vault_and_unowned_fields_before_writes() { + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + let mut request = json!({"rpc":"workspace.write","params":{"vault_id":"another-vault","path":"a.md","expected":"","content":"test","operation_id":uuid::Uuid::new_v4().to_string()}}); + assert_eq!( + dispatch(&mut ws, &request).unwrap_err(), + "VAULT_PERMISSION_CHANGED" + ); + request["params"]["vault_id"] = json!(ws.vault_id); + request["params"]["origin"] = json!("remote"); + assert_eq!( + dispatch(&mut ws, &request).unwrap_err(), + "WORKSPACE_REQUEST_INVALID" + ); + assert!(!root.path().join("a.md").exists()); + request["params"].as_object_mut().unwrap().remove("origin"); + assert_eq!(dispatch(&mut ws, &request).unwrap()["state"], "committed"); + assert_eq!(ws.pending_count().unwrap(), 1); + } +} diff --git a/frontend/src-tauri/tests/core_workspace.rs b/frontend/src-tauri/tests/core_workspace.rs new file mode 100644 index 0000000..a94be1a --- /dev/null +++ b/frontend/src-tauri/tests/core_workspace.rs @@ -0,0 +1,154 @@ +#![cfg(feature = "desktop")] +//! Real Python Core + Host pipes + isolated Workspace; no personal data or Provider. +use notesagent_host::{core::CoreSupervisor, workspace::Workspace, workspace_broker}; +use serde_json::{json, Value}; +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; + +async fn request( + core: &mut CoreSupervisor, + method: &str, + path: &str, + vault: &str, + operation: &str, + body: Option, +) -> (u16, Value) { + let session = core.request_session(path).unwrap(); + let mut request = reqwest::Client::new() + .request(method.parse::().unwrap(), session.url) + .header("Authorization", session.authorization.as_str()) + .header("X-Core-Generation", session.generation) + .header("X-OpenNexus-Vault", vault) + .header("X-Request-Id", operation); + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.unwrap(); + let status = response.status().as_u16(); + (status, response.json().await.unwrap()) +} + +#[tokio::test] +async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits() { + let backend = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../backend") + .canonicalize() + .unwrap(); + let python = backend.join(if cfg!(windows) { + ".venv/Scripts/python.exe" + } else { + ".venv/bin/python" + }); + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("vault"); + std::fs::create_dir(&root).unwrap(); + let workspace = Arc::new(Mutex::new(Workspace::open(&root).unwrap())); + let vault = workspace.lock().unwrap().vault_id.clone(); + let handler = workspace.clone(); + let mut core = CoreSupervisor::new( + python, + vec!["-m".into(), "app.sidecar".into()], + backend, + temp.path().join("core"), + ) + .with_broker(Arc::new(move |value| { + workspace_broker::dispatch(&mut handler.lock().unwrap(), value) + })); + let operation = uuid::Uuid::new_v4().to_string(); + let (status, created) = request( + &mut core, + "POST", + "/api/notes", + &vault, + &operation, + Some(json!({"title":"Core fixture","markdown":"# 中文\noriginal","tags":["fixture"]})), + ) + .await; + assert_eq!(status, 200, "{created}"); + let file_id = created["note_id"].as_str().unwrap(); + let original = workspace.lock().unwrap().read("Core fixture.md").unwrap(); + assert_eq!(original.entry.file_id, file_id); + assert!(original.content.contains("original")); + assert_eq!( + workspace + .lock() + .unwrap() + .operation(&operation) + .unwrap() + .unwrap()["state"], + "committed" + ); + let (status, listed) = request( + &mut core, + "GET", + "/api/notes", + &vault, + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 200, "{listed}"); + assert_eq!(listed["items"][0]["note_id"], file_id); + let next = uuid::Uuid::new_v4().to_string(); + let (status, updated) = request( + &mut core, + "PATCH", + &format!("/api/notes/{file_id}"), + &vault, + &next, + Some(json!({"markdown":"# New\nchanged","expected_content_hash":original.entry.hash})), + ) + .await; + assert_eq!(status, 200, "{updated}"); + let (status, conflict) = request( + &mut core, + "PATCH", + &format!("/api/notes/{file_id}"), + &vault, + &uuid::Uuid::new_v4().to_string(), + Some(json!({"markdown":"must-not-overwrite","expected_content_hash":original.entry.hash})), + ) + .await; + assert_eq!(status, 409, "{conflict}"); + let (status, moved) = request( + &mut core, + "POST", + &format!("/api/notes/{file_id}/move"), + &vault, + &uuid::Uuid::new_v4().to_string(), + Some(json!({"folder":"nested"})), + ) + .await; + assert_eq!(status, 200, "{moved}"); + assert_eq!(moved["note_id"], file_id); + assert!(root.join("nested/Core fixture.md").is_file()); + let (status, denied) = request( + &mut core, + "GET", + &format!("/api/notes/{file_id}"), + &uuid::Uuid::new_v4().to_string(), + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 409, "{denied}"); + assert_eq!(denied["error"]["code"], "VAULT_PERMISSION_CHANGED"); + let (status, deleted) = request( + &mut core, + "DELETE", + &format!("/api/notes/{file_id}"), + &vault, + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 200, "{deleted}"); + assert!(!root.join("nested/Core fixture.md").exists()); + assert_eq!(workspace.lock().unwrap().pending_count().unwrap(), 4); + assert!(!temp + .path() + .join("core/unbound-vault/Core fixture.md") + .exists()); +}