Skip to content

Commit 66d4471

Browse files
authored
fix(storage): canonicalize shorthand namespace URIs on write (#1929)
1 parent ba54e38 commit 66d4471

10 files changed

Lines changed: 102 additions & 15 deletions

File tree

docs/en/concepts/04-viking-uri.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ viking://agent/memories/patterns/ # Learned patterns
9999
viking://agent/instructions/ # Agent instructions
100100
```
101101

102+
The short `viking://user/...` and `viking://agent/...` forms above are
103+
relative to the current request identity. OpenViking expands them internally to
104+
explicit namespace paths such as `viking://user/{user_id}/...` and
105+
`viking://agent/{agent_id}/...` before storage and retrieval.
106+
102107
### Session Data
103108

104109
```

docs/en/concepts/05-storage.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ VikingFS is the unified URI abstraction layer that hides underlying storage deta
4141
### URI Mapping
4242

4343
```
44-
viking://resources/docs/auth → /local/resources/docs/auth
45-
viking://user/memories → /local/user/memories
46-
viking://agent/skills → /local/agent/skills
44+
viking://resources/docs/auth → /local/{account_id}/resources/docs/auth
45+
viking://user/memories → /local/{account_id}/user/{user_id}/memories
46+
viking://agent/skills → /local/{account_id}/agent/{agent_id}/skills
4747
```
4848

4949
### Core API

docs/zh/concepts/04-viking-uri.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ viking://agent/memories/patterns/ # 学习的模式
9898
viking://agent/instructions/ # Agent 指令
9999
```
100100

101+
上面的 `viking://user/...``viking://agent/...` 短路径会按当前请求身份解析。
102+
OpenViking 会在存储和检索前将它们展开为显式命名空间路径,例如
103+
`viking://user/{user_id}/...``viking://agent/{agent_id}/...`
104+
101105
### 会话数据
102106

103107
```

docs/zh/concepts/05-storage.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ VikingFS 是统一的 URI 抽象层,屏蔽底层存储细节。
3939
### URI 映射
4040

4141
```
42-
viking://resources/docs/auth → /local/resources/docs/auth
43-
viking://user/memories → /local/user/memories
44-
viking://agent/skills → /local/agent/skills
42+
viking://resources/docs/auth → /local/{account_id}/resources/docs/auth
43+
viking://user/memories → /local/{account_id}/user/{user_id}/memories
44+
viking://agent/skills → /local/{account_id}/agent/{agent_id}/skills
4545
```
4646

4747
### 核心 API

openviking/core/namespace.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,11 @@ def uri_parts(uri: str) -> list[str]:
101101

102102
def _content_segment_index(parts: tuple[str, ...]) -> Optional[int]:
103103
"""Return the first content segment after a user/agent namespace root."""
104-
if len(parts) < 3 or parts[0] not in _CONTENT_TYPES_BY_SCOPE:
104+
if len(parts) < 2 or parts[0] not in _CONTENT_TYPES_BY_SCOPE:
105+
return None
106+
if parts[1] in _CONTENT_TYPES_BY_SCOPE[parts[0]]:
107+
return 1
108+
if len(parts) < 3:
105109
return None
106110
cross_scope_segment = _CROSS_SCOPE_OWNER_SEGMENT[parts[0]]
107111
if len(parts) >= 5 and parts[2] == cross_scope_segment:

openviking/storage/content_write.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import os
88
from typing import Any, Dict, Optional
99

10-
from openviking.core.namespace import context_type_for_uri
10+
from openviking.core.namespace import NamespaceShapeError, canonicalize_uri, context_type_for_uri
1111
from openviking.resource.watch_storage import is_watch_task_control_uri
1212
from openviking.server.identity import RequestContext
1313
from openviking.session.memory.utils.content import deserialize_full, serialize_with_metadata
@@ -52,7 +52,10 @@ async def write(
5252
wait: bool = False,
5353
timeout: Optional[float] = None,
5454
) -> Dict[str, Any]:
55-
normalized_uri = VikingURI.normalize(uri)
55+
try:
56+
normalized_uri = canonicalize_uri(uri, ctx)
57+
except NamespaceShapeError as exc:
58+
raise InvalidArgumentError(str(exc)) from exc
5659
self._validate_mode(mode)
5760
self._validate_target_uri(normalized_uri)
5861

openviking/storage/queuefs/embedding_msg_converter.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,19 @@ def from_context(context: Context) -> EmbeddingMsg:
3434
if not context_data.get("account_id"):
3535
user = context_data.get("user") or {}
3636
context_data["account_id"] = user.get("account_id", "default")
37-
if context_data.get("owner_user_id") is None and context_data.get("owner_agent_id") is None:
37+
uri = context_data.get("uri", "")
38+
owner_fields = None
39+
if uri:
3840
owner_fields = owner_fields_for_uri(
39-
context_data.get("uri", ""),
41+
uri,
4042
user=context.user,
4143
account_id=context_data.get("account_id"),
4244
)
43-
context_data["owner_user_id"] = owner_fields["owner_user_id"]
44-
context_data["owner_agent_id"] = owner_fields["owner_agent_id"]
45+
context_data["uri"] = owner_fields["uri"]
46+
if context_data.get("owner_user_id") is None and context_data.get("owner_agent_id") is None:
47+
if owner_fields is not None:
48+
context_data["owner_user_id"] = owner_fields["owner_user_id"]
49+
context_data["owner_agent_id"] = owner_fields["owner_agent_id"]
4550

4651
# Derive level field for hierarchical retrieval.
4752
uri = context_data.get("uri", "")

tests/server/test_content_write_service.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,62 @@ async def _fake_wait_for_queues(*, timeout):
510510
assert write_calls == [(file_uri, "new content")]
511511

512512

513+
@pytest.mark.asyncio
514+
async def test_create_mode_canonicalizes_user_shorthand_memory_uri(monkeypatch):
515+
input_uri = "viking://user/memories/new_file.md"
516+
canonical_uri = "viking://user/default/memories/new_file.md"
517+
root_uri = "viking://user/default/memories"
518+
ctx = RequestContext(user=UserIdentifier.the_default_user(), role=Role.USER)
519+
viking_fs = _FakeVikingFSForCreate(
520+
file_uri=canonical_uri,
521+
root_uri=root_uri,
522+
file_exists=False,
523+
)
524+
coordinator = ContentWriteCoordinator(viking_fs=viking_fs)
525+
lock_manager = _FakeLockManager()
526+
527+
monkeypatch.setattr("openviking.storage.content_write.get_lock_manager", lambda: lock_manager)
528+
529+
write_calls = []
530+
vectorize_calls = []
531+
refresh_calls = []
532+
533+
async def _fake_write_in_place(uri, content, *, mode, ctx):
534+
del mode, ctx
535+
write_calls.append((uri, content))
536+
return content
537+
538+
async def _fake_vectorize_single_file(uri, *, context_type, ctx):
539+
del ctx
540+
vectorize_calls.append((uri, context_type))
541+
return None
542+
543+
async def _fake_enqueue_memory_refresh(**kwargs):
544+
refresh_calls.append(kwargs)
545+
return None
546+
547+
async def _fake_wait_for_queues(*, timeout):
548+
del timeout
549+
return None
550+
551+
monkeypatch.setattr(coordinator, "_write_in_place", _fake_write_in_place)
552+
monkeypatch.setattr(coordinator, "_vectorize_single_file", _fake_vectorize_single_file)
553+
monkeypatch.setattr(coordinator, "_enqueue_memory_refresh", _fake_enqueue_memory_refresh)
554+
monkeypatch.setattr(coordinator, "_wait_for_queues", _fake_wait_for_queues)
555+
556+
result = await coordinator.write(
557+
uri=input_uri, content="new content", mode="create", ctx=ctx, wait=True
558+
)
559+
560+
assert result["uri"] == canonical_uri
561+
assert result["root_uri"] == root_uri
562+
assert result["context_type"] == "memory"
563+
assert write_calls == [(canonical_uri, "new content")]
564+
assert vectorize_calls == [(canonical_uri, "memory")]
565+
assert refresh_calls[0]["root_uri"] == root_uri
566+
assert refresh_calls[0]["modified_uri"] == canonical_uri
567+
568+
513569
@pytest.mark.asyncio
514570
async def test_create_mode_existing_file_raises_409(monkeypatch):
515571
file_uri = "viking://user/default/memories/existing.md"

tests/storage/test_embedding_msg_converter_tenant.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,30 @@
1111

1212

1313
@pytest.mark.parametrize(
14-
("uri", "expected_owner_user_id", "expected_owner_agent_id"),
14+
("uri", "expected_uri", "expected_owner_user_id", "expected_owner_agent_id"),
1515
[
1616
(
1717
"viking://user/memories/preferences/me.md",
18+
lambda user: f"viking://user/{user.user_id}/memories/preferences/me.md",
1819
lambda user: user.user_id,
1920
None,
2021
),
2122
(
2223
"viking://agent/memories/cases/me.md",
24+
lambda user: f"viking://agent/{user.agent_id}/memories/cases/me.md",
2325
None,
2426
lambda user: user.agent_id,
2527
),
2628
(
29+
"viking://resources/doc.md",
2730
"viking://resources/doc.md",
2831
None,
2932
None,
3033
),
3134
],
3235
)
3336
def test_embedding_msg_converter_backfills_account_and_owner_fields(
34-
uri, expected_owner_user_id, expected_owner_agent_id
37+
uri, expected_uri, expected_owner_user_id, expected_owner_agent_id
3538
):
3639
user = UserIdentifier("acme", "alice", "helper")
3740
context = Context(uri=uri, abstract="hello", user=user)
@@ -45,6 +48,8 @@ def test_embedding_msg_converter_backfills_account_and_owner_fields(
4548

4649
assert msg is not None
4750
assert msg.context_data["account_id"] == "acme"
51+
resolved_uri = expected_uri(user) if callable(expected_uri) else expected_uri
52+
assert msg.context_data["uri"] == resolved_uri
4853
expected_user = (
4954
expected_owner_user_id(user) if callable(expected_owner_user_id) else expected_owner_user_id
5055
)

tests/unit/test_namespace_uri_classification.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
def test_context_type_for_uri_uses_path_segments():
1515
assert context_type_for_uri("viking://user/alice/memories/entities/m1.md") == "memory"
16+
assert context_type_for_uri("viking://user/memories/entities/m1.md") == "memory"
17+
assert context_type_for_uri("viking://agent/memories/cases/m1.md") == "memory"
18+
assert context_type_for_uri("viking://agent/skills/demo") == "skill"
1619
assert context_type_for_uri("viking://agent/default/memories/cases/m1.md") == "memory"
1720
assert (
1821
context_type_for_uri("viking://user/alice/agent/default/memories/entities/m1.md")
@@ -27,10 +30,12 @@ def test_context_type_for_uri_uses_path_segments():
2730
def test_exact_memory_and_skill_root_detection():
2831
assert classify_uri("viking://user/alice/memories/preferences/prefs.md").is_memory
2932
assert classify_uri("viking://user/alice/memories").is_memory_root
33+
assert classify_uri("viking://user/memories").is_memory_root
3034
assert not classify_uri("viking://user/alice/memories/preferences").is_memory_root
3135

3236
assert classify_uri("viking://agent/default/skills/demo/SKILL.md").is_skill
3337
assert classify_uri("viking://agent/default/skills/demo").is_skill_root
38+
assert classify_uri("viking://agent/skills/demo").is_skill_root
3439
assert not classify_uri("viking://agent/default/skills").is_skill_root
3540
assert not classify_uri("viking://agent/default/skills/demo/assets").is_skill_root
3641

0 commit comments

Comments
 (0)