feat: 通过绑定的工作区路由 Core 笔记并保存持久操作回执
This commit is contained in:
@@ -117,10 +117,16 @@ class ToolRegistry:
|
|||||||
duration_ms=round((perf_counter() - started) * 1000),
|
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:
|
try:
|
||||||
output = registered.executor(arguments, context)
|
output = registered.executor(arguments, context)
|
||||||
if inspect.isawaitable(output):
|
if inspect.isawaitable(output):
|
||||||
output = await 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(
|
return ToolResult(
|
||||||
tool_call_id=call.tool_call_id,
|
tool_call_id=call.tool_call_id,
|
||||||
name=call.name,
|
name=call.name,
|
||||||
@@ -146,3 +152,5 @@ class ToolRegistry:
|
|||||||
error_message=str(exc),
|
error_message=str(exc),
|
||||||
duration_ms=round((perf_counter() - started) * 1000),
|
duration_ms=round((perf_counter() - started) * 1000),
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
host_bridge.operation_id.reset(operation_token)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class HostBridge:
|
|||||||
request_id = uuid.uuid4().hex
|
request_id = uuid.uuid4().hex
|
||||||
result = queue.Queue(maxsize=1)
|
result = queue.Queue(maxsize=1)
|
||||||
payload = json.dumps({"rpc": method, "request_id": request_id, "params": params}, separators=(",", ":"))
|
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")
|
raise RuntimeError("HOST_REQUEST_TOO_LARGE")
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.closed.is_set():
|
if self.closed.is_set():
|
||||||
@@ -42,8 +42,8 @@ class HostBridge:
|
|||||||
|
|
||||||
def listen(self, on_disconnect):
|
def listen(self, on_disconnect):
|
||||||
try:
|
try:
|
||||||
while line := self.reader.readline(131073):
|
while line := self.reader.readline((8 * 1024 * 1024 + 1)):
|
||||||
if len(line) > 131072:
|
if len(line) > (8 * 1024 * 1024):
|
||||||
break
|
break
|
||||||
message = json.loads(line)
|
message = json.loads(line)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -65,3 +65,8 @@ class HostBridge:
|
|||||||
|
|
||||||
|
|
||||||
active: HostBridge | None = None
|
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)
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ def serialized_vault_mutation(operation):
|
|||||||
@wraps(operation)
|
@wraps(operation)
|
||||||
async def wrapped(*args, **kwargs):
|
async def wrapped(*args, **kwargs):
|
||||||
async with vault_mutation_lock():
|
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():
|
with web_vault_ownership():
|
||||||
return await operation(*args, **kwargs)
|
return await operation(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
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)
|
record = repository.get_note_record(note_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
return 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]:
|
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)
|
items, total = repository.list_note_summaries(limit=limit, offset=offset, folder=folder, tag=tag)
|
||||||
return [NoteSummary(**item) for item in items], total
|
return [NoteSummary(**item) for item in items], total
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -77,7 +77,16 @@ class SessionAuth:
|
|||||||
(b"cache-control", b"no-store")]})
|
(b"cache-control", b"no-store")]})
|
||||||
await send({"type": "http.response.body", "body": body})
|
await send({"type": "http.response.body", "body": body})
|
||||||
return
|
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:
|
def main() -> int:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
| `workspace_tree` | Markdown 与目录树;file_id/path/hash/revision/deleted/is_folder |
|
| `workspace_tree` | Markdown 与目录树;file_id/path/hash/revision/deleted/is_folder |
|
||||||
| `workspace_read` | path,返回条目与 UTF-8 content |
|
| `workspace_read` | path,返回条目与 UTF-8 content |
|
||||||
| `workspace_write` | path、expected(SHA-256)、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_rename` | path、destination、expected;文件 ID 保持不变 |
|
||||||
| `workspace_delete` | path、expected;正文保存在 `.ainote/trash` |
|
| `workspace_delete` | path、expected;正文保存在 `.ainote/trash` |
|
||||||
| `workspace_mkdir` | path,相对当前授权 Vault |
|
| `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 不会被测试读取或迁移。
|
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 目前只持久化,**尚无上传/拉取循环**。
|
保存前对比实际磁盘 SHA-256,先持久化包含内容的 journal,再同目录临时文件刷盘和替换,最后提交元数据及 outbox。文件已替换但数据库未提交时,启动恢复补齐同一 operation_id。摘要不匹配则保留 journal 冲突及外部正文。remote origin 不再次入 outbox。outbox 目前只持久化,**尚无上传/拉取循环**。
|
||||||
|
|||||||
@@ -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)。
|
- 已修复多实例凭据覆盖与桌面请求取消/超时,证据见[全量回归](OpenNexus验收修复与回归-2026-09-08.md)。
|
||||||
- 新增加密凭据备份、原生确认恢复、恢复前留存和逐条完整性验证。Windows 新增 WTS 锁屏/注销/断开通知,原子撤销解析资格;解锁不会随系统返回而自动恢复。设置页同步锁定状态,提供备份和恢复入口。
|
- 新增加密凭据备份、原生确认恢复、恢复前留存和逐条完整性验证。Windows 新增 WTS 锁屏/注销/断开通知,原子撤销解析资格;解锁不会随系统返回而自动恢复。设置页同步锁定状态,提供备份和恢复入口。
|
||||||
- 备份恢复、错误口令保护、锁屏后解析/写入拒绝、原生窗口通知测试通过;后者向专用测试窗口注入消息,不等于真实锁屏时延验收。前端两项交互测试、类型检查、Rust desktop 全目标 Clippy 通过。凭据旧库确认清除和迁移故障矩阵仍待补齐。
|
- 备份恢复、错误口令保护、锁屏后解析/写入拒绝、原生窗口通知测试通过;后者向专用测试窗口注入消息,不等于真实锁屏时延验收。前端两项交互测试、类型检查、Rust desktop 全目标 Clippy 通过。凭据旧库确认清除和迁移故障矩阵仍待补齐。
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ fn main() {
|
|||||||
"workspace_tree",
|
"workspace_tree",
|
||||||
"workspace_read",
|
"workspace_read",
|
||||||
"workspace_write",
|
"workspace_write",
|
||||||
|
"workspace_operation",
|
||||||
"workspace_rename",
|
"workspace_rename",
|
||||||
"workspace_delete",
|
"workspace_delete",
|
||||||
"workspace_mkdir",
|
"workspace_mkdir",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"allow-workspace-tree",
|
"allow-workspace-tree",
|
||||||
"allow-workspace-read",
|
"allow-workspace-read",
|
||||||
"allow-workspace-write",
|
"allow-workspace-write",
|
||||||
|
"allow-workspace-operation",
|
||||||
"allow-workspace-rename",
|
"allow-workspace-rename",
|
||||||
"allow-workspace-delete",
|
"allow-workspace-delete",
|
||||||
"allow-workspace-mkdir",
|
"allow-workspace-mkdir",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -363,11 +363,15 @@ impl CoreSupervisor {
|
|||||||
let mut reader = BufReader::new(stdout);
|
let mut reader = BufReader::new(stdout);
|
||||||
loop {
|
loop {
|
||||||
let mut line = Zeroizing::new(Vec::new());
|
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,
|
Ok(0) | Err(_) => break,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
if line.len() > 131072 {
|
if line.len() > 8 * 1024 * 1024 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let Ok(message) = serde_json::from_slice::<serde_json::Value>(&line) else {
|
let Ok(message) = serde_json::from_slice::<serde_json::Value>(&line) else {
|
||||||
@@ -393,7 +397,7 @@ impl CoreSupervisor {
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let mut bytes = Zeroizing::new(bytes);
|
let mut bytes = Zeroizing::new(bytes);
|
||||||
if bytes.len() > 131072 {
|
if bytes.len() > 8 * 1024 * 1024 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
bytes.push(b'\n');
|
bytes.push(b'\n');
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ mod runtime_compat;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod session_lock;
|
pub mod session_lock;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
pub mod workspace_broker;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use zeroize::Zeroizing;
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Host {
|
struct Host {
|
||||||
requests: Requests,
|
requests: Requests,
|
||||||
workspace: Mutex<Option<Workspace>>,
|
workspace: Arc<Mutex<Option<Workspace>>>,
|
||||||
recent: Mutex<Option<RecentVaultStore>>,
|
recent: Mutex<Option<RecentVaultStore>>,
|
||||||
core: Arc<Mutex<Option<CoreSupervisor>>>,
|
core: Arc<Mutex<Option<CoreSupervisor>>>,
|
||||||
credentials: Arc<Mutex<Option<CredentialBroker>>>,
|
credentials: Arc<Mutex<Option<CredentialBroker>>>,
|
||||||
@@ -162,6 +162,12 @@ async fn core_request(request: CoreRequest, host: State<'_, Host>) -> Result<Cor
|
|||||||
content_type,
|
content_type,
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
} = request;
|
} = request;
|
||||||
|
let vault_id = host
|
||||||
|
.workspace
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.as_ref()
|
||||||
|
.map(|ws| ws.vault_id.clone());
|
||||||
let mut lease = host.requests.claim(&request_id)?;
|
let mut lease = host.requests.claim(&request_id)?;
|
||||||
let checkpoint = lease.checkpoint();
|
let checkpoint = lease.checkpoint();
|
||||||
lease
|
lease
|
||||||
@@ -212,6 +218,9 @@ async fn core_request(request: CoreRequest, host: State<'_, Host>) -> Result<Cor
|
|||||||
)
|
)
|
||||||
.header("X-Core-Generation", &session.generation)
|
.header("X-Core-Generation", &session.generation)
|
||||||
.header("X-Request-Id", &request_id);
|
.header("X-Request-Id", &request_id);
|
||||||
|
if let Some(vault_id) = &vault_id {
|
||||||
|
request = request.header("X-OpenNexus-Vault", vault_id);
|
||||||
|
}
|
||||||
if let Some(value) = body {
|
if let Some(value) = body {
|
||||||
request = request.json(&value);
|
request = request.json(&value);
|
||||||
}
|
}
|
||||||
@@ -321,6 +330,12 @@ fn core_stream(
|
|||||||
return Err("CORE_HEADER_INVALID".into());
|
return Err("CORE_HEADER_INVALID".into());
|
||||||
}
|
}
|
||||||
let core = host.core.clone();
|
let core = host.core.clone();
|
||||||
|
let vault_id = host
|
||||||
|
.workspace
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.as_ref()
|
||||||
|
.map(|ws| ws.vault_id.clone());
|
||||||
let streams = host.streams.clone();
|
let streams = host.streams.clone();
|
||||||
let mut running = host.streams.lock().map_err(|_| "HOST_BUSY")?;
|
let mut running = host.streams.lock().map_err(|_| "HOST_BUSY")?;
|
||||||
if running.len() >= 16 || running.contains_key(&request_id) {
|
if running.len() >= 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)
|
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("Authorization", session.authorization.as_str())
|
||||||
.header("X-Core-Generation", session.generation).header("Accept", "text/event-stream");
|
.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(body) = body { request = request.json(&body); }
|
||||||
if let Some(id) = last_event_id { request = request.header("Last-Event-ID", id); }
|
if let Some(id) = last_event_id { request = request.header("Last-Event-ID", id); }
|
||||||
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
||||||
@@ -619,12 +635,32 @@ fn workspace_write(
|
|||||||
path: String,
|
path: String,
|
||||||
expected: String,
|
expected: String,
|
||||||
content: String,
|
content: String,
|
||||||
|
operation_id: Option<String>,
|
||||||
) -> Result<Entry, String> {
|
) -> Result<Entry, String> {
|
||||||
with_workspace(&host, |ws| {
|
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]
|
#[tauri::command]
|
||||||
|
fn workspace_operation(
|
||||||
|
host: State<'_, Host>,
|
||||||
|
vault_id: String,
|
||||||
|
operation_id: String,
|
||||||
|
) -> Result<Option<serde_json::Value>, 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(
|
fn workspace_rename(
|
||||||
host: State<'_, Host>,
|
host: State<'_, Host>,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -730,7 +766,21 @@ fn main() {
|
|||||||
include_str!(concat!(env!("OUT_DIR"), "/core-manifest.json")).to_owned(),
|
include_str!(concat!(env!("OUT_DIR"), "/core-manifest.json")).to_owned(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let workspace_state = app.state::<Host>().workspace.clone();
|
||||||
let core = core.with_broker(Arc::new(move |request| {
|
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
|
credential_state
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "HOST_BUSY")?
|
.map_err(|_| "HOST_BUSY")?
|
||||||
@@ -786,6 +836,7 @@ fn main() {
|
|||||||
workspace_tree,
|
workspace_tree,
|
||||||
workspace_read,
|
workspace_read,
|
||||||
workspace_write,
|
workspace_write,
|
||||||
|
workspace_operation,
|
||||||
workspace_rename,
|
workspace_rename,
|
||||||
workspace_delete,
|
workspace_delete,
|
||||||
workspace_mkdir
|
workspace_mkdir
|
||||||
|
|||||||
@@ -135,16 +135,22 @@ impl Workspace {
|
|||||||
let db = Connection::open(db_path)?;
|
let db = Connection::open(db_path)?;
|
||||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
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"));
|
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;
|
db.execute_batch("BEGIN IMMEDIATE;
|
||||||
CREATE TABLE IF NOT EXISTS identity (id TEXT NOT NULL);
|
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 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 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 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');
|
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
|
let vault_id: String = db
|
||||||
.query_row("SELECT id FROM identity", [], |r| r.get(0))
|
.query_row("SELECT id FROM identity", [], |r| r.get(0))
|
||||||
.optional()?
|
.optional()?
|
||||||
@@ -281,17 +287,36 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn read(&mut self, path: &str) -> Result<Document> {
|
pub fn read(&mut self, path: &str) -> Result<Document> {
|
||||||
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
|
let entry = self
|
||||||
.entry(path)?
|
.entry(path)?
|
||||||
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
|
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
|
||||||
let content = fs::read_to_string(self.resolve(path)?)?;
|
|
||||||
if hash(content.as_bytes()) != entry.hash {
|
if hash(content.as_bytes()) != entry.hash {
|
||||||
return Err(HostError::new("REVISION_CONFLICT"));
|
return Err(HostError::new("REVISION_CONFLICT"));
|
||||||
}
|
}
|
||||||
Ok(Document { entry, content })
|
Ok(Document { entry, content })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn path_for_id(&self, file_id: &str) -> Result<String> {
|
||||||
|
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(
|
pub fn write(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -299,12 +324,73 @@ impl Workspace {
|
|||||||
content: &[u8],
|
content: &[u8],
|
||||||
origin: &str,
|
origin: &str,
|
||||||
) -> Result<Entry> {
|
) -> Result<Entry> {
|
||||||
|
self.write_operation(path, expected, content, origin, &Uuid::new_v4().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn operation(&self, operation_id: &str) -> Result<Option<serde_json::Value>> {
|
||||||
|
let value: Option<(String, Option<String>)> = 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<Entry> = 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<Entry> {
|
||||||
|
if Uuid::parse_str(operation_id).is_err() {
|
||||||
|
return Err(HostError::new("OPERATION_ID_INVALID"));
|
||||||
|
}
|
||||||
if content.len() > 100 * 1024 * 1024 {
|
if content.len() > 100 * 1024 * 1024 {
|
||||||
return Err(HostError::new("FILE_TOO_LARGE"));
|
return Err(HostError::new("FILE_TOO_LARGE"));
|
||||||
}
|
}
|
||||||
if origin != "local" && origin != "remote" {
|
if origin != "local" && origin != "remote" {
|
||||||
return Err(HostError::new("INVALID_ORIGIN"));
|
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<String> = 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 target = self.resolve(path)?;
|
||||||
let current = if target.exists() {
|
let current = if target.exists() {
|
||||||
hash(&fs::read(&target)?)
|
hash(&fs::read(&target)?)
|
||||||
@@ -317,12 +403,17 @@ impl Workspace {
|
|||||||
let file_id = self
|
let file_id = self
|
||||||
.entry(path)?
|
.entry(path)?
|
||||||
.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id);
|
.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id);
|
||||||
let operation_id = Uuid::new_v4().to_string();
|
let tx = self.db.transaction()?;
|
||||||
self.db.execute(
|
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')",
|
"INSERT INTO journal VALUES (?1,?2,?3,?4,?5,?6,'pending')",
|
||||||
params![operation_id, file_id, path, expected, content, origin],
|
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)?
|
self.entry(path)?
|
||||||
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))
|
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))
|
||||||
}
|
}
|
||||||
@@ -344,10 +435,16 @@ impl Workspace {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
if current != expected && current != digest {
|
if current != expected && current != digest {
|
||||||
self.db.execute(
|
let tx = self.db.transaction()?;
|
||||||
|
tx.execute(
|
||||||
"UPDATE journal SET state='conflict' WHERE operation_id=?1",
|
"UPDATE journal SET state='conflict' WHERE operation_id=?1",
|
||||||
[operation_id],
|
[operation_id],
|
||||||
)?;
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE operations SET state='conflict' WHERE operation_id=?1",
|
||||||
|
[operation_id],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
return Err(HostError::new("RECOVERY_CONFLICT"));
|
return Err(HostError::new("RECOVERY_CONFLICT"));
|
||||||
}
|
}
|
||||||
if current != digest {
|
if current != digest {
|
||||||
@@ -369,6 +466,25 @@ impl Workspace {
|
|||||||
if origin == "local" {
|
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])?;
|
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.execute("DELETE FROM journal WHERE operation_id=?1", [operation_id])?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -451,6 +567,76 @@ impl Workspace {
|
|||||||
destination: &str,
|
destination: &str,
|
||||||
expected: &str,
|
expected: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<String> {
|
||||||
|
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<String> = 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)?;
|
let source = self.resolve(path)?;
|
||||||
if !source.is_file() {
|
if !source.is_file() {
|
||||||
return Err(HostError::new("FILE_NOT_FOUND"));
|
return Err(HostError::new("FILE_NOT_FOUND"));
|
||||||
@@ -461,12 +647,17 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
self.entry(path)?
|
self.entry(path)?
|
||||||
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
|
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
|
||||||
let id = Uuid::new_v4().to_string();
|
let tx = self.db.transaction()?;
|
||||||
self.db.execute(
|
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')",
|
"INSERT INTO file_ops VALUES (?1,?2,?3,?4,?5,?6,'pending')",
|
||||||
params![id, kind, path, destination, expected, content],
|
params![id, kind, path, destination, expected, content],
|
||||||
)?;
|
)?;
|
||||||
Ok(id)
|
tx.commit()?;
|
||||||
|
Ok(id.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_file_op(&mut self, id: &str) -> Result<()> {
|
fn apply_file_op(&mut self, id: &str) -> Result<()> {
|
||||||
@@ -499,8 +690,13 @@ impl Workspace {
|
|||||||
let target_conflict =
|
let target_conflict =
|
||||||
target.exists() && (linked(&target)? || hash(&fs::read(&target)?) != expected);
|
target.exists() && (linked(&target)? || hash(&fs::read(&target)?) != expected);
|
||||||
if source_conflict || target_conflict {
|
if source_conflict || target_conflict {
|
||||||
self.db
|
let tx = self.db.transaction()?;
|
||||||
.execute("UPDATE file_ops SET state='conflict' WHERE id=?1", [id])?;
|
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"));
|
return Err(HostError::new("RECOVERY_CONFLICT"));
|
||||||
}
|
}
|
||||||
// journal 保留完整内容,目标刷盘后才删除来源;两处崩溃均可幂等重放。
|
// 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("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])?;
|
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()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -599,6 +809,68 @@ mod tests {
|
|||||||
assert_eq!(ws.pending_count().unwrap(), 2);
|
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]
|
#[test]
|
||||||
fn unsafe_paths_external_change_and_remote_origin() {
|
fn unsafe_paths_external_change_and_remote_origin() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -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<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T, String> {
|
||||||
|
serde_json::from_value(value.clone()).map_err(|_| "WORKSPACE_REQUEST_INVALID".into())
|
||||||
|
}
|
||||||
|
pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||||
|
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::<Vec<_>>(),"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<std::time::SystemTime>| {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Value>,
|
||||||
|
) -> (u16, Value) {
|
||||||
|
let session = core.request_session(path).unwrap();
|
||||||
|
let mut request = reqwest::Client::new()
|
||||||
|
.request(method.parse::<reqwest::Method>().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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user